From c7352aefa32da8905a666a328f30ccf1b0600f03 Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 10 Dec 2021 16:44:49 +1100 Subject: [PATCH 01/18] scroll indicies based on name, for nd needs to be based on sortname from nd api --- src/smapi.ts | 66 ++++++++++++++++++++++++++++++++++++++++++ tests/smapi.test.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/smapi.ts b/src/smapi.ts index 9d5686b..d06c6d8 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -366,6 +366,54 @@ export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({ albumArtURI: defaultArtistArtURI(bonobUrl, artist).href(), }); +export const scrollIndicesFrom = (artists: ArtistSummary[]) => { + const indicies: Record = { + "A":undefined, + "B":undefined, + "C":undefined, + "D":undefined, + "E":undefined, + "F":undefined, + "G":undefined, + "H":undefined, + "I":undefined, + "J":undefined, + "K":undefined, + "L":undefined, + "M":undefined, + "N":undefined, + "O":undefined, + "P":undefined, + "Q":undefined, + "R":undefined, + "S":undefined, + "T":undefined, + "U":undefined, + "V":undefined, + "W":undefined, + "X":undefined, + "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]!; + if(Object.keys(indicies).includes(char) && indicies[char] == undefined) { + indicies[char] = i; + } + } + var lastIndex = 0; + const result: string[] = []; + Object.entries(indicies).forEach(([letter, index]) => { + result.push(letter); + if(index) { + lastIndex = index; + } + result.push(`${lastIndex}`); + }) + return result.join(",") +} + function splitId(id: string) { const [type, typeId] = id.split(":"); return (t: T) => ({ @@ -707,6 +755,7 @@ function bindSmapiSoapServiceToExpress( title: lang("artists"), albumArtURI: iconArtURI(bonobUrl, "artists").href(), itemType: "container", + canScroll: true, }, { id: "albums", @@ -945,6 +994,23 @@ function bindSmapiSoapServiceToExpress( throw `Unsupported getMetadata id=${id}`; } }), + getScrollIndices: async ( + { id }: { id: string }, + _, + soapyHeaders: SoapyHeaders + ) => { + switch(id) { + case "artists": { + return login(soapyHeaders?.credentials) + .then(({ musicLibrary }) => musicLibrary.artists({ _index: 0, _count: 999999999 })) + .then((artists) => ({ + getScrollIndicesResult: scrollIndicesFrom(artists.results) + })) + } + default: + throw `Unsupported getScrollIndices id=${id}`; + } + }, createContainer: async ( { title, seedId }: { title: string; seedId: string | undefined }, _, diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index fbbae83..df5b500 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -26,6 +26,7 @@ import { sonosifyMimeType, ratingAsInt, ratingFromInt, + scrollIndicesFrom, } from "../src/smapi"; import { keys as i8nKeys } from "../src/i8n"; @@ -56,7 +57,7 @@ import dayjs from "dayjs"; import url, { URLBuilder } from "../src/url_builder"; import { iconForGenre } from "../src/icon"; import { formatForURL } from "../src/burn"; -import { range } from "underscore"; +import _, { range } from "underscore"; import { FixedClock } from "../src/clock"; import { ExpiredTokenError, InvalidTokenError, SmapiAuthTokens, SmapiToken, ToSmapiFault } from "../src/smapi_auth"; @@ -860,6 +861,29 @@ 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("wsdl api", () => { const musicService = { generateToken: jest.fn(), @@ -1410,6 +1434,7 @@ describe("wsdl api", () => { title: "Artists", albumArtURI: iconArtURI(bonobUrl, "artists").href(), itemType: "container", + canScroll: true, }, { id: "albums", @@ -1498,6 +1523,7 @@ describe("wsdl api", () => { title: "Artiesten", albumArtURI: iconArtURI(bonobUrl, "artists").href(), itemType: "container", + canScroll: true, }, { id: "albums", @@ -3111,6 +3137,48 @@ describe("wsdl api", () => { }); }); + describe("getScrollIndices", () => { + itShouldHandleInvalidCredentials((ws) => + ws.getScrollIndicesAsync({ id: `artists` }) + ); + + describe("for artists", () => { + let ws: Client; + + const artist1 = anArtist({ name: "Aerosmith" }); + const artist2 = anArtist({ name: "Bob Marley" }); + const artist3 = anArtist({ name: "Beatles" }); + const artist4 = anArtist({ name: "Cat Empire" }); + const artist5 = anArtist({ name: "Metallica" }); + const artist6 = anArtist({ name: "Yellow Brick Road" }); + + beforeEach(async () => { + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); + setupAuthenticatedRequest(ws); + musicLibrary.artists.mockResolvedValue({ + results: [artist1, artist2, artist3, artist4, artist5, artist6], + total: 6 + }); + }); + + it("should return paging information", async () => { + const root = await ws.getScrollIndicesAsync({ + id: `artists`, + }); + + expect(root[0]).toEqual({ + getScrollIndicesResult: scrollIndicesFrom([artist1, artist2, artist3, artist4, artist5, artist6]) + }); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); + expect(musicLibrary.artists).toHaveBeenCalledWith({ _index: 0, _count: 999999999 }); + }); + }); + }); + describe("createContainer", () => { let ws: Client; From 00944a7a25f42a0641ef52ca6155ec749c6adaab Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 24 Dec 2021 17:22:10 +1100 Subject: [PATCH 02/18] scoll indices based on ND sort name for artists --- src/music_service.ts | 15 +- src/smapi.ts | 11 +- src/subsonic.ts | 68 +++- tests/in_memory_music_service.test.ts | 22 +- tests/in_memory_music_service.ts | 2 +- tests/music_service.test.ts | 52 ++- tests/smapi.test.ts | 68 +++- tests/subsonic.test.ts | 546 +++++++++++++++++++------- 8 files changed, 594 insertions(+), 190 deletions(-) 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", () => { + 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))); + .mockImplementationOnce(() => + Promise.resolve( + ok( + subsonicOK({ + artists: { + index: [ + { + name: "#", + }, + { + name: "A", + }, + { + name: "B", + }, + ], + }, + }) + ) + ) + ); }); - - it("should return the single artist", async () => { + + it("should return empty", 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, + results: [], + total: 0, }); }); }); - }); - - 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 there no indexes and no artists", () => { beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) + Promise.resolve( + ok( + subsonicOK({ + artists: {}, + }) + ) + ) ); }); - - 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 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 })); + expect(axios.get).toHaveBeenCalledWith(`${url}/api/artist`, { + params: asURLSearchParams({ + _sort: "name", + _order: "ASC", + _start: "2" + }), + headers: { + "User-Agent": "bonob", + "x-nd-authorization": `Bearer ${bearer}`, + }, + }); + }); + }); - const expectedResults = [artist2, artist3].map((it) => ({ - id: it.id, - image: it.image, - name: it.name, - })); + 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" + } + }) + ); - expect(artists).toEqual({ results: expectedResults, total: 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: 2, _count: 23 })); + + expect(artists).toEqual({ + results: [ + artistSummaryFromNDArtist(ndArtist3), + artistSummaryFromNDArtist(ndArtist4), + ], + total: 5, + }); - 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", + _end: "25" + }), + headers: { + "User-Agent": "bonob", + "x-nd-authorization": `Bearer ${bearer}`, + }, }); }); }); + }); }); From 1c94a6d5658f111827f77408d3e097d58a14d233 Mon Sep 17 00:00:00 2001 From: simojenki Date: Wed, 5 Jan 2022 10:15:01 +1100 Subject: [PATCH 03/18] Move subsonic generic library into proper class --- src/subsonic.ts | 656 +++++++++++++++++++++++++----------------------- 1 file changed, 346 insertions(+), 310 deletions(-) diff --git a/src/subsonic.ts b/src/subsonic.ts index 89440f8..28527ea 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -431,6 +431,312 @@ interface SubsonicMusicLibrary extends MusicLibrary { ): TE.TaskEither; } +export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { + subsonic: Subsonic; + credentials: SubsonicCredentials; + + constructor(subsonic: Subsonic, credentials: SubsonicCredentials) { + this.subsonic = subsonic; + this.credentials = credentials; + } + + flavour = () => "subsonic"; + + bearerToken = (_: Credentials) => TE.right(undefined); + + artists = (q: ArtistQuery): Promise> => + this.subsonic + .getArtists(this.credentials) + .then(slice2(q)) + .then(([page, total]) => ({ + total, + results: page.map((it) => ({ + id: it.id, + name: it.name, + sortName: it.name, + image: it.image, + })), + })); + + artist = async (id: string): Promise => + this.subsonic.getArtistWithInfo(this.credentials, id); + + albums = async (q: AlbumQuery): Promise> => + this.subsonic.getAlbumList2(this.credentials, q); + + album = (id: string): Promise => + this.subsonic.getAlbum(this.credentials, id); + + genres = () => + this.subsonic + .getJSON(this.credentials, "/rest/getGenres") + .then((it) => + pipe( + it.genres.genre || [], + A.filter((it) => it.albumCount > 0), + A.map((it) => it.value), + A.sort(ordString), + A.map((it) => ({ id: b64Encode(it), name: it })) + ) + ); + + tracks = (albumId: string) => + this.subsonic + .getJSON(this.credentials, "/rest/getAlbum", { + id: albumId, + }) + .then((it) => it.album) + .then((album) => + (album.song || []).map((song) => asTrack(asAlbum(album), song)) + ); + + track = (trackId: string) => + this.subsonic.getTrack(this.credentials, trackId); + + rate = (trackId: string, rating: Rating) => + Promise.resolve(true) + .then(() => { + if (rating.stars >= 0 && rating.stars <= 5) { + return this.subsonic.getTrack(this.credentials, trackId); + } else { + throw `Invalid rating.stars value of ${rating.stars}`; + } + }) + .then((track) => { + const thingsToUpdate = []; + if (track.rating.love != rating.love) { + thingsToUpdate.push( + this.subsonic.getJSON( + this.credentials, + `/rest/${rating.love ? "star" : "unstar"}`, + { + id: trackId, + } + ) + ); + } + if (track.rating.stars != rating.stars) { + thingsToUpdate.push( + this.subsonic.getJSON(this.credentials, `/rest/setRating`, { + id: trackId, + rating: rating.stars, + }) + ); + } + return Promise.all(thingsToUpdate); + }) + .then(() => true) + .catch(() => false); + + stream = async ({ + trackId, + range, + }: { + trackId: string; + range: string | undefined; + }) => + this.subsonic.getTrack(this.credentials, trackId).then((track) => + this.subsonic + .get( + this.credentials, + `/rest/stream`, + { + id: trackId, + c: this.subsonic.streamClientApplication(track), + }, + { + headers: pipe( + range, + O.fromNullable, + O.map((range) => ({ + "User-Agent": USER_AGENT, + Range: range, + })), + O.getOrElse(() => ({ + "User-Agent": USER_AGENT, + })) + ), + responseType: "stream", + } + ) + .then((res) => ({ + status: res.status, + headers: { + "content-type": res.headers["content-type"], + "content-length": res.headers["content-length"], + "content-range": res.headers["content-range"], + "accept-ranges": res.headers["accept-ranges"], + }, + stream: res.data, + })) + ); + + coverArt = async (coverArtURN: BUrn, size?: number) => + Promise.resolve(coverArtURN) + .then((it) => assertSystem(it, "subsonic")) + .then((it) => it.resource.split(":")[1]!) + .then((it) => this.subsonic.getCoverArt(this.credentials, it, size)) + .then((res) => ({ + contentType: res.headers["content-type"], + data: Buffer.from(res.data, "binary"), + })) + .catch((e) => { + logger.error(`Failed getting coverArt for urn:'${coverArtURN}': ${e}`); + return undefined; + }); + + scrobble = async (id: string) => + this.subsonic + .getJSON(this.credentials, `/rest/scrobble`, { + id, + submission: true, + }) + .then((_) => true) + .catch(() => false); + + nowPlaying = async (id: string) => + this.subsonic + .getJSON(this.credentials, `/rest/scrobble`, { + id, + submission: false, + }) + .then((_) => true) + .catch(() => false); + + searchArtists = async (query: string) => + this.subsonic + .search3(this.credentials, { query, artistCount: 20 }) + .then(({ artists }) => + artists.map((artist) => ({ + id: artist.id, + name: artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), + })) + ); + + searchAlbums = async (query: string) => + this.subsonic + .search3(this.credentials, { query, albumCount: 20 }) + .then(({ albums }) => this.subsonic.toAlbumSummary(albums)); + + searchTracks = async (query: string) => + this.subsonic + .search3(this.credentials, { query, songCount: 20 }) + .then(({ songs }) => + Promise.all( + songs.map((it) => this.subsonic.getTrack(this.credentials, it.id)) + ) + ); + + playlists = async () => + this.subsonic + .getJSON(this.credentials, "/rest/getPlaylists") + .then((it) => it.playlists.playlist || []) + .then((playlists) => + playlists.map((it) => ({ id: it.id, name: it.name })) + ); + + playlist = async (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/getPlaylist", { + id, + }) + .then((it) => it.playlist) + .then((playlist) => { + let trackNumber = 1; + return { + id: playlist.id, + name: playlist.name, + entries: (playlist.entry || []).map((entry) => ({ + ...asTrack( + { + id: entry.albumId!, + name: entry.album!, + year: entry.year, + genre: maybeAsGenre(entry.genre), + artistName: entry.artist, + artistId: entry.artistId, + coverArt: coverArtURN(entry.coverArt), + }, + entry + ), + number: trackNumber++, + })), + }; + }); + + createPlaylist = async (name: string) => + this.subsonic + .getJSON(this.credentials, "/rest/createPlaylist", { + name, + }) + .then((it) => it.playlist) + .then((it) => ({ id: it.id, name: it.name })); + + deletePlaylist = async (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/deletePlaylist", { + id, + }) + .then((_) => true); + + addToPlaylist = async (playlistId: string, trackId: string) => + this.subsonic + .getJSON(this.credentials, "/rest/updatePlaylist", { + playlistId, + songIdToAdd: trackId, + }) + .then((_) => true); + + removeFromPlaylist = async (playlistId: string, indicies: number[]) => + this.subsonic + .getJSON(this.credentials, "/rest/updatePlaylist", { + playlistId, + songIndexToRemove: indicies, + }) + .then((_) => true); + + similarSongs = async (id: string) => + this.subsonic + .getJSON( + this.credentials, + "/rest/getSimilarSongs2", + { id, count: 50 } + ) + .then((it) => it.similarSongs2.song || []) + .then((songs) => + Promise.all( + songs.map((song) => + this.subsonic + .getAlbum(this.credentials, song.albumId!) + .then((album) => asTrack(album, song)) + ) + ) + ); + + topSongs = async (artistId: string) => + this.subsonic.getArtist(this.credentials, artistId).then(({ name }) => + this.subsonic + .getJSON(this.credentials, "/rest/getTopSongs", { + artist: name, + count: 50, + }) + .then((it) => it.topSongs.song || []) + .then((songs) => + Promise.all( + songs.map((song) => + this.subsonic + .getAlbum(this.credentials, song.albumId!) + .then((album) => asTrack(album, song)) + ) + ) + ) + ); +} + export class Subsonic implements MusicService { url: string; streamClientApplication: StreamClientApplication; @@ -699,281 +1005,8 @@ export class Subsonic implements MusicService { private libraryFor = ( credentials: SubsonicCredentials ): Promise => { - const subsonic = this; - - const genericSubsonic: SubsonicMusicLibrary = { - flavour: () => "subsonic", - bearerToken: (_: Credentials) => TE.right(undefined), - artists: (q: ArtistQuery): Promise> => - subsonic - .getArtists(credentials) - .then(slice2(q)) - .then(([page, total]) => ({ - total, - results: page.map((it) => ({ - id: it.id, - name: it.name, - sortName: it.name, - image: it.image, - })), - })), - artist: async (id: string): Promise => - subsonic.getArtistWithInfo(credentials, id), - albums: async (q: AlbumQuery): Promise> => - subsonic.getAlbumList2(credentials, q), - album: (id: string): Promise => subsonic.getAlbum(credentials, id), - genres: () => - subsonic - .getJSON(credentials, "/rest/getGenres") - .then((it) => - pipe( - it.genres.genre || [], - A.filter((it) => it.albumCount > 0), - A.map((it) => it.value), - A.sort(ordString), - A.map((it) => ({ id: b64Encode(it), name: it })) - ) - ), - tracks: (albumId: string) => - subsonic - .getJSON(credentials, "/rest/getAlbum", { - id: albumId, - }) - .then((it) => it.album) - .then((album) => - (album.song || []).map((song) => asTrack(asAlbum(album), song)) - ), - track: (trackId: string) => subsonic.getTrack(credentials, trackId), - rate: (trackId: string, rating: Rating) => - Promise.resolve(true) - .then(() => { - if (rating.stars >= 0 && rating.stars <= 5) { - return subsonic.getTrack(credentials, trackId); - } else { - throw `Invalid rating.stars value of ${rating.stars}`; - } - }) - .then((track) => { - const thingsToUpdate = []; - if (track.rating.love != rating.love) { - thingsToUpdate.push( - subsonic.getJSON( - credentials, - `/rest/${rating.love ? "star" : "unstar"}`, - { - id: trackId, - } - ) - ); - } - if (track.rating.stars != rating.stars) { - thingsToUpdate.push( - subsonic.getJSON(credentials, `/rest/setRating`, { - id: trackId, - rating: rating.stars, - }) - ); - } - return Promise.all(thingsToUpdate); - }) - .then(() => true) - .catch(() => false), - stream: async ({ - trackId, - range, - }: { - trackId: string; - range: string | undefined; - }) => - subsonic.getTrack(credentials, trackId).then((track) => - subsonic - .get( - credentials, - `/rest/stream`, - { - id: trackId, - c: this.streamClientApplication(track), - }, - { - headers: pipe( - range, - O.fromNullable, - O.map((range) => ({ - "User-Agent": USER_AGENT, - Range: range, - })), - O.getOrElse(() => ({ - "User-Agent": USER_AGENT, - })) - ), - responseType: "stream", - } - ) - .then((res) => ({ - status: res.status, - headers: { - "content-type": res.headers["content-type"], - "content-length": res.headers["content-length"], - "content-range": res.headers["content-range"], - "accept-ranges": res.headers["accept-ranges"], - }, - stream: res.data, - })) - ), - coverArt: async (coverArtURN: BUrn, size?: number) => - Promise.resolve(coverArtURN) - .then((it) => assertSystem(it, "subsonic")) - .then((it) => it.resource.split(":")[1]!) - .then((it) => subsonic.getCoverArt(credentials, it, size)) - .then((res) => ({ - contentType: res.headers["content-type"], - data: Buffer.from(res.data, "binary"), - })) - .catch((e) => { - logger.error( - `Failed getting coverArt for urn:'${coverArtURN}': ${e}` - ); - return undefined; - }), - scrobble: async (id: string) => - subsonic - .getJSON(credentials, `/rest/scrobble`, { - id, - submission: true, - }) - .then((_) => true) - .catch(() => false), - nowPlaying: async (id: string) => - subsonic - .getJSON(credentials, `/rest/scrobble`, { - id, - submission: false, - }) - .then((_) => true) - .catch(() => false), - searchArtists: async (query: string) => - subsonic - .search3(credentials, { query, artistCount: 20 }) - .then(({ artists }) => - artists.map((artist) => ({ - id: artist.id, - name: artist.name, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: artist.artistImageUrl, - }), - })) - ), - searchAlbums: async (query: string) => - subsonic - .search3(credentials, { query, albumCount: 20 }) - .then(({ albums }) => subsonic.toAlbumSummary(albums)), - searchTracks: async (query: string) => - subsonic - .search3(credentials, { query, songCount: 20 }) - .then(({ songs }) => - Promise.all( - songs.map((it) => subsonic.getTrack(credentials, it.id)) - ) - ), - playlists: async () => - subsonic - .getJSON(credentials, "/rest/getPlaylists") - .then((it) => it.playlists.playlist || []) - .then((playlists) => - playlists.map((it) => ({ id: it.id, name: it.name })) - ), - playlist: async (id: string) => - subsonic - .getJSON(credentials, "/rest/getPlaylist", { - id, - }) - .then((it) => it.playlist) - .then((playlist) => { - let trackNumber = 1; - return { - id: playlist.id, - name: playlist.name, - entries: (playlist.entry || []).map((entry) => ({ - ...asTrack( - { - id: entry.albumId!, - name: entry.album!, - year: entry.year, - genre: maybeAsGenre(entry.genre), - artistName: entry.artist, - artistId: entry.artistId, - coverArt: coverArtURN(entry.coverArt), - }, - entry - ), - number: trackNumber++, - })), - }; - }), - createPlaylist: async (name: string) => - subsonic - .getJSON(credentials, "/rest/createPlaylist", { - name, - }) - .then((it) => it.playlist) - .then((it) => ({ id: it.id, name: it.name })), - deletePlaylist: async (id: string) => - subsonic - .getJSON(credentials, "/rest/deletePlaylist", { - id, - }) - .then((_) => true), - addToPlaylist: async (playlistId: string, trackId: string) => - subsonic - .getJSON(credentials, "/rest/updatePlaylist", { - playlistId, - songIdToAdd: trackId, - }) - .then((_) => true), - removeFromPlaylist: async (playlistId: string, indicies: number[]) => - subsonic - .getJSON(credentials, "/rest/updatePlaylist", { - playlistId, - songIndexToRemove: indicies, - }) - .then((_) => true), - similarSongs: async (id: string) => - subsonic - .getJSON( - credentials, - "/rest/getSimilarSongs2", - { id, count: 50 } - ) - .then((it) => it.similarSongs2.song || []) - .then((songs) => - Promise.all( - songs.map((song) => - subsonic - .getAlbum(credentials, song.albumId!) - .then((album) => asTrack(album, song)) - ) - ) - ), - topSongs: async (artistId: string) => - subsonic.getArtist(credentials, artistId).then(({ name }) => - subsonic - .getJSON(credentials, "/rest/getTopSongs", { - artist: name, - count: 50, - }) - .then((it) => it.topSongs.song || []) - .then((songs) => - Promise.all( - songs.map((song) => - subsonic - .getAlbum(credentials, song.albumId!) - .then((album) => asTrack(album, song)) - ) - ) - ) - ), - }; + const genericSubsonic: SubsonicMusicLibrary = + new SubsonicGenericMusicLibrary(this, credentials); if (credentials.type == "navidrome") { return Promise.resolve({ @@ -991,46 +1024,49 @@ 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", + 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, }; - 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") - })) } + + 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); } }; } + +export default Subsonic; From 6ad39ce04426ad1635af0d4ca3425309056b0f1e Mon Sep 17 00:00:00 2001 From: simojenki Date: Wed, 5 Jan 2022 12:06:53 +1100 Subject: [PATCH 04/18] refactor --- src/subsonic.ts | 370 ++++++++++++++++++++--------------------- tests/subsonic.test.ts | 5 - 2 files changed, 176 insertions(+), 199 deletions(-) diff --git a/src/subsonic.ts b/src/subsonic.ts index 28527ea..e9e5bbd 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -203,13 +203,6 @@ type GetSongResponse = { song: song; }; -type GetStarredResponse = { - starred2: { - song: song[]; - album: album[]; - }; -}; - export type PingResponse = { status: string; version: string; @@ -445,8 +438,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { bearerToken = (_: Credentials) => TE.right(undefined); artists = (q: ArtistQuery): Promise> => - this.subsonic - .getArtists(this.credentials) + this.getArtists() .then(slice2(q)) .then(([page, total]) => ({ total, @@ -459,13 +451,12 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { })); artist = async (id: string): Promise => - this.subsonic.getArtistWithInfo(this.credentials, id); + this.getArtistWithInfo(id); albums = async (q: AlbumQuery): Promise> => - this.subsonic.getAlbumList2(this.credentials, q); + this.getAlbumList2(q); - album = (id: string): Promise => - this.subsonic.getAlbum(this.credentials, id); + album = (id: string): Promise => this.getAlbum(id); genres = () => this.subsonic @@ -490,14 +481,13 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { (album.song || []).map((song) => asTrack(asAlbum(album), song)) ); - track = (trackId: string) => - this.subsonic.getTrack(this.credentials, trackId); + track = (trackId: string) => this.getTrack(trackId); rate = (trackId: string, rating: Rating) => Promise.resolve(true) .then(() => { if (rating.stars >= 0 && rating.stars <= 5) { - return this.subsonic.getTrack(this.credentials, trackId); + return this.getTrack(trackId); } else { throw `Invalid rating.stars value of ${rating.stars}`; } @@ -535,7 +525,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { trackId: string; range: string | undefined; }) => - this.subsonic.getTrack(this.credentials, trackId).then((track) => + this.getTrack(trackId).then((track) => this.subsonic .get( this.credentials, @@ -575,7 +565,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { Promise.resolve(coverArtURN) .then((it) => assertSystem(it, "subsonic")) .then((it) => it.resource.split(":")[1]!) - .then((it) => this.subsonic.getCoverArt(this.credentials, it, size)) + .then((it) => this.getCoverArt(this.credentials, it, size)) .then((res) => ({ contentType: res.headers["content-type"], data: Buffer.from(res.data, "binary"), @@ -604,9 +594,8 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { .catch(() => false); searchArtists = async (query: string) => - this.subsonic - .search3(this.credentials, { query, artistCount: 20 }) - .then(({ artists }) => + this.search3({ query, artistCount: 20 }).then( + ({ artists }) => artists.map((artist) => ({ id: artist.id, name: artist.name, @@ -615,21 +604,17 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { artistImageURL: artist.artistImageUrl, }), })) - ); + ); searchAlbums = async (query: string) => - this.subsonic - .search3(this.credentials, { query, albumCount: 20 }) - .then(({ albums }) => this.subsonic.toAlbumSummary(albums)); + this.search3({ query, albumCount: 20 }).then( + ({ albums }) => this.toAlbumSummary(albums) + ); searchTracks = async (query: string) => - this.subsonic - .search3(this.credentials, { query, songCount: 20 }) - .then(({ songs }) => - Promise.all( - songs.map((it) => this.subsonic.getTrack(this.credentials, it.id)) - ) - ); + this.search3({ query, songCount: 20 }).then(({ songs }) => + Promise.all(songs.map((it) => this.getTrack(it.id))) + ); playlists = async () => this.subsonic @@ -710,15 +695,15 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { .then((songs) => Promise.all( songs.map((song) => - this.subsonic - .getAlbum(this.credentials, song.albumId!) - .then((album) => asTrack(album, song)) + this.getAlbum(song.albumId!).then((album) => + asTrack(album, song) + ) ) ) ); topSongs = async (artistId: string) => - this.subsonic.getArtist(this.credentials, artistId).then(({ name }) => + this.getArtist(artistId).then(({ name }) => this.subsonic .getJSON(this.credentials, "/rest/getTopSongs", { artist: name, @@ -728,111 +713,17 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { .then((songs) => Promise.all( songs.map((song) => - this.subsonic - .getAlbum(this.credentials, song.albumId!) - .then((album) => asTrack(album, song)) + this.getAlbum(song.albumId!).then((album) => + asTrack(album, song) + ) ) ) ) ); -} - -export class Subsonic implements MusicService { - url: string; - streamClientApplication: StreamClientApplication; - externalImageFetcher: ImageFetcher; - - constructor( - url: string, - streamClientApplication: StreamClientApplication = DEFAULT, - externalImageFetcher: ImageFetcher = axiosImageFetcher - ) { - this.url = url; - this.streamClientApplication = streamClientApplication; - this.externalImageFetcher = externalImageFetcher; - } - get = async ( - { username, password }: Credentials, - path: string, - q: {} = {}, - config: AxiosRequestConfig | undefined = {} - ) => - axios - .get(`${this.url}${path}`, { - params: asURLSearchParams({ - u: username, - v: "1.16.1", - c: DEFAULT_CLIENT_APPLICATION, - ...t_and_s(password), - ...q, - }), - headers: { - "User-Agent": USER_AGENT, - }, - ...config, - }) - .catch((e) => { - throw `Subsonic failed with: ${e}`; - }) - .then((response) => { - if (response.status != 200 && response.status != 206) { - throw `Subsonic failed with a ${response.status || "no!"} status`; - } else return response; - }); - - getJSON = async ( - { username, password }: Credentials, - path: string, - q: {} = {} - ): Promise => - this.get({ username, password }, path, { f: "json", ...q }) - .then((response) => response.data as SubsonicEnvelope) - .then((json) => json["subsonic-response"]) - .then((json) => { - if (isError(json)) throw `Subsonic error:${json.error.message}`; - else return json as unknown as T; - }); - - generateToken = (credentials: Credentials) => - pipe( - TE.tryCatch( - () => - this.getJSON( - _.pick(credentials, "username", "password"), - "/rest/ping.view" - ), - (e) => new AuthFailure(e as string) - ), - TE.chain(({ type }) => - pipe( - TE.tryCatch( - () => this.libraryFor({ ...credentials, type, bearer: undefined }), - () => new AuthFailure("Failed to get library") - ), - TE.map((library) => ({ type, library })) - ) - ), - TE.chain(({ library, type }) => - pipe( - library.bearerToken(credentials), - TE.map((bearer) => ({ bearer, type })) - ) - ), - TE.map(({ bearer, type }) => ({ - serviceToken: asToken({ ...credentials, bearer, type }), - userId: credentials.username, - nickname: credentials.username, - })) - ); - - refreshToken = (serviceToken: string) => - this.generateToken(parseToken(serviceToken)); - - getArtists = ( - credentials: Credentials - ): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> => - this.getJSON(credentials, "/rest/getArtists") + private getArtists = (): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> => + this.subsonic + .getJSON(this.credentials, "/rest/getArtists") .then((it) => (it.artists.index || []).flatMap((it) => it.artist || [])) .then((artists) => artists.map((artist) => ({ @@ -846,8 +737,7 @@ export class Subsonic implements MusicService { })) ); - getArtistInfo = ( - credentials: Credentials, + private getArtistInfo = ( id: string ): Promise<{ similarArtist: (ArtistSummary & { inLibrary: boolean })[]; @@ -857,11 +747,12 @@ export class Subsonic implements MusicService { l: string | undefined; }; }> => - this.getJSON(credentials, "/rest/getArtistInfo2", { - id, - count: 50, - includeNotPresent: true, - }) + this.subsonic + .getJSON(this.credentials, "/rest/getArtistInfo2", { + id, + count: 50, + includeNotPresent: true, + }) .then((it) => it.artistInfo2) .then((it) => ({ images: { @@ -880,8 +771,9 @@ export class Subsonic implements MusicService { })), })); - getAlbum = (credentials: Credentials, id: string): Promise => - this.getJSON(credentials, "/rest/getAlbum", { id }) + private getAlbum = (id: string): Promise => + this.subsonic + .getJSON(this.credentials, "/rest/getAlbum", { id }) .then((it) => it.album) .then((album) => ({ id: album.id, @@ -893,15 +785,15 @@ export class Subsonic implements MusicService { coverArt: coverArtURN(album.coverArt), })); - getArtist = ( - credentials: Credentials, + private getArtist = ( id: string ): Promise< IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] } > => - this.getJSON(credentials, "/rest/getArtist", { - id, - }) + this.subsonic + .getJSON(this.credentials, "/rest/getArtist", { + id, + }) .then((it) => it.artist) .then((it) => ({ id: it.id, @@ -910,10 +802,10 @@ export class Subsonic implements MusicService { albums: this.toAlbumSummary(it.album || []), })); - getArtistWithInfo = (credentials: Credentials, id: string) => + private getArtistWithInfo = (id: string) => Promise.all([ - this.getArtist(credentials, id), - this.getArtistInfo(credentials, id), + this.getArtist(id), + this.getArtistInfo(id), ]).then(([artist, artistInfo]) => ({ id: artist.id, name: artist.name, @@ -930,29 +822,30 @@ export class Subsonic implements MusicService { similarArtists: artistInfo.similarArtist, })); - getCoverArt = (credentials: Credentials, id: string, size?: number) => - this.get(credentials, "/rest/getCoverArt", size ? { id, size } : { id }, { - headers: { "User-Agent": "bonob" }, - responseType: "arraybuffer", - }); + private getCoverArt = (credentials: Credentials, id: string, size?: number) => + this.subsonic.get( + credentials, + "/rest/getCoverArt", + size ? { id, size } : { id }, + { + headers: { "User-Agent": "bonob" }, + responseType: "arraybuffer", + } + ); - getTrack = (credentials: Credentials, id: string) => - this.getJSON(credentials, "/rest/getSong", { - id, - }) + private getTrack = (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/getSong", { + id, + }) .then((it) => it.song) .then((song) => - this.getAlbum(credentials, song.albumId!).then((album) => + this.getAlbum(song.albumId!).then((album) => asTrack(album, song) ) ); - getStarred = (credentials: Credentials) => - this.getJSON(credentials, "/rest/getStarred2").then( - (it) => new Set(it.starred2.song.map((it) => it.id)) - ); - - toAlbumSummary = (albumList: album[]): AlbumSummary[] => + private toAlbumSummary = (albumList: album[]): AlbumSummary[] => albumList.map((album) => ({ id: album.id, name: album.name, @@ -963,42 +856,131 @@ export class Subsonic implements MusicService { coverArt: coverArtURN(album.coverArt), })); - search3 = (credentials: Credentials, q: any) => - this.getJSON(credentials, "/rest/search3", { - artistCount: 0, - albumCount: 0, - songCount: 0, - ...q, - }).then((it) => ({ - artists: it.searchResult3.artist || [], - albums: it.searchResult3.album || [], - songs: it.searchResult3.song || [], - })); + private search3 = (q: any) => + this.subsonic + .getJSON(this.credentials, "/rest/search3", { + artistCount: 0, + albumCount: 0, + songCount: 0, + ...q, + }) + .then((it) => ({ + artists: it.searchResult3.artist || [], + albums: it.searchResult3.album || [], + songs: it.searchResult3.song || [], + })); - getAlbumList2 = (credentials: Credentials, q: AlbumQuery) => + private getAlbumList2 = (q: AlbumQuery) => Promise.all([ - this.getArtists(credentials).then((it) => + this.getArtists().then((it) => _.inject(it, (total, artist) => total + artist.albumCount, 0) ), - this.getJSON(credentials, "/rest/getAlbumList2", { - type: AlbumQueryTypeToSubsonicType[q.type], - ...(q.genre ? { genre: b64Decode(q.genre) } : {}), - size: 500, - offset: q._index, - }) + this.subsonic + .getJSON(this.credentials, "/rest/getAlbumList2", { + type: AlbumQueryTypeToSubsonicType[q.type], + ...(q.genre ? { genre: b64Decode(q.genre) } : {}), + size: 500, + offset: q._index, + }) .then((response) => response.albumList2.album || []) .then(this.toAlbumSummary), ]).then(([total, albums]) => ({ results: albums.slice(0, q._count), total: albums.length == 500 ? total : (q._index || 0) + albums.length, })); +} + +export class Subsonic implements MusicService { + url: string; + streamClientApplication: StreamClientApplication; + externalImageFetcher: ImageFetcher; - // getStarred2 = (credentials: Credentials): Promise<{ albums: Album[] }> => - // this.getJSON(credentials, "/rest/getStarred2") - // .then((it) => it.starred2) - // .then((it) => ({ - // albums: it.album.map(asAlbum), - // })); + constructor( + url: string, + streamClientApplication: StreamClientApplication = DEFAULT, + externalImageFetcher: ImageFetcher = axiosImageFetcher + ) { + this.url = url; + this.streamClientApplication = streamClientApplication; + this.externalImageFetcher = externalImageFetcher; + } + + get = async ( + { username, password }: Credentials, + path: string, + q: {} = {}, + config: AxiosRequestConfig | undefined = {} + ) => + axios + .get(`${this.url}${path}`, { + params: asURLSearchParams({ + u: username, + v: "1.16.1", + c: DEFAULT_CLIENT_APPLICATION, + ...t_and_s(password), + ...q, + }), + headers: { + "User-Agent": USER_AGENT, + }, + ...config, + }) + .catch((e) => { + throw `Subsonic failed with: ${e}`; + }) + .then((response) => { + if (response.status != 200 && response.status != 206) { + throw `Subsonic failed with a ${response.status || "no!"} status`; + } else return response; + }); + + getJSON = async ( + { username, password }: Credentials, + path: string, + q: {} = {} + ): Promise => + this.get({ username, password }, path, { f: "json", ...q }) + .then((response) => response.data as SubsonicEnvelope) + .then((json) => json["subsonic-response"]) + .then((json) => { + if (isError(json)) throw `Subsonic error:${json.error.message}`; + else return json as unknown as T; + }); + + generateToken = (credentials: Credentials) => + pipe( + TE.tryCatch( + () => + this.getJSON( + _.pick(credentials, "username", "password"), + "/rest/ping.view" + ), + (e) => new AuthFailure(e as string) + ), + TE.chain(({ type }) => + pipe( + TE.tryCatch( + () => this.libraryFor({ ...credentials, type, bearer: undefined }), + () => new AuthFailure("Failed to get library") + ), + TE.map((library) => ({ type, library })) + ) + ), + TE.chain(({ library, type }) => + pipe( + library.bearerToken(credentials), + TE.map((bearer) => ({ bearer, type })) + ) + ), + TE.map(({ bearer, type }) => ({ + serviceToken: asToken({ ...credentials, bearer, type }), + userId: credentials.username, + nickname: credentials.username, + })) + ); + + refreshToken = (serviceToken: string) => + this.generateToken(parseToken(serviceToken)); login = async (token: string) => this.libraryFor(parseToken(token)); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 6e2ca2d..13ba320 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -375,11 +375,6 @@ const getAlbumJson = (artist: Artist, album: Album, tracks: Track[]) => const getSongJson = (track: Track) => subsonicOK({ song: asSongJson(track) }); -// const getStarredJson = ({ albums }: { albums: Album[] }) => subsonicOK({starred2: { -// album: albums.map(it => asAlbumJson({ id: it.artistId, name: it.artistName }, it, [])), -// song: [], -// }}) - const subsonicOK = (body: any = {}) => ({ "subsonic-response": { status: "ok", From 88661d7c260de291f3a02ccb7ab58cf799e3e879 Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 7 Jan 2022 12:12:49 +1100 Subject: [PATCH 05/18] refactor --- src/app.ts | 3 +- src/images.ts | 48 ++++++++++++++++++++++++++ src/server.ts | 2 +- src/subsonic.ts | 54 ++--------------------------- src/utils.ts | 10 ++++++ tests/images.test.ts | 78 ++++++++++++++++++++++++++++++++++++++++++ tests/subsonic.test.ts | 75 +--------------------------------------- 7 files changed, 141 insertions(+), 129 deletions(-) create mode 100644 src/images.ts create mode 100644 tests/images.test.ts diff --git a/src/app.ts b/src/app.ts index 970c18d..162ecbd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,8 +5,6 @@ import logger from "./logger"; import { appendMimeTypeToClientFor, - axiosImageFetcher, - cachingImageFetcher, DEFAULT, Subsonic, } from "./subsonic"; @@ -17,6 +15,7 @@ import sonos, { bonobService } from "./sonos"; import { MusicService } from "./music_service"; import { SystemClock } from "./clock"; import { JWTSmapiLoginTokens } from "./smapi_auth"; +import { axiosImageFetcher, cachingImageFetcher } from "./images"; const config = readConfig(); const clock = SystemClock; diff --git a/src/images.ts b/src/images.ts new file mode 100644 index 0000000..a5b1522 --- /dev/null +++ b/src/images.ts @@ -0,0 +1,48 @@ + +import sharp from "sharp"; +import fse from "fs-extra"; +import path from "path"; +import { Md5 } from "ts-md5/dist/md5"; +import axios from "axios"; + +import { CoverArt } from "./music_service"; +import { BROWSER_HEADERS } from "./utils"; + +export type ImageFetcher = (url: string) => Promise; + +export const cachingImageFetcher = + (cacheDir: string, delegate: ImageFetcher) => + async (url: string): Promise => { + const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); + return fse + .readFile(filename) + .then((data) => ({ contentType: "image/png", data })) + .catch(() => + delegate(url).then((image) => { + if (image) { + return sharp(image.data) + .png() + .toBuffer() + .then((png) => { + return fse + .writeFile(filename, png) + .then(() => ({ contentType: "image/png", data: png })); + }); + } else { + return undefined; + } + }) + ); + }; + +export const axiosImageFetcher = (url: string): Promise => + axios + .get(url, { + headers: BROWSER_HEADERS, + responseType: "arraybuffer", + }) + .then((res) => ({ + contentType: res.headers["content-type"], + data: Buffer.from(res.data, "binary"), + })) + .catch(() => undefined); diff --git a/src/server.ts b/src/server.ts index fc6bd65..34d4f55 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,7 +35,7 @@ import _, { shuffle } from "underscore"; import morgan from "morgan"; import { takeWithRepeats } from "./utils"; import { parse } from "./burn"; -import { axiosImageFetcher, ImageFetcher } from "./subsonic"; +import { axiosImageFetcher, ImageFetcher } from "./images"; import { JWTSmapiLoginTokens, SmapiAuthTokens, diff --git a/src/subsonic.ts b/src/subsonic.ts index e9e5bbd..bfd82d3 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -15,17 +15,13 @@ import { AlbumSummary, Genre, Track, - CoverArt, Rating, AlbumQueryType, Artist, AuthFailure, Sortable, } from "./music_service"; -import sharp from "sharp"; import _ from "underscore"; -import fse from "fs-extra"; -import path from "path"; import axios, { AxiosRequestConfig } from "axios"; import randomstring from "randomstring"; @@ -33,16 +29,8 @@ import { b64Encode, b64Decode } from "./b64"; import logger from "./logger"; import { assertSystem, BUrn } from "./burn"; import { artist } from "./smapi"; +import { axiosImageFetcher, ImageFetcher } from "./images"; -export const BROWSER_HEADERS = { - accept: - "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "accept-encoding": "gzip, deflate, br", - "accept-language": "en-GB,en;q=0.5", - "upgrade-insecure-requests": "1", - "user-agent": - "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0", -}; export const t = (password: string, s: string) => Md5.hashStr(`${password}${s}`); @@ -353,45 +341,6 @@ export const asURLSearchParams = (q: any) => { return urlSearchParams; }; -export type ImageFetcher = (url: string) => Promise; - -export const cachingImageFetcher = - (cacheDir: string, delegate: ImageFetcher) => - async (url: string): Promise => { - const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); - return fse - .readFile(filename) - .then((data) => ({ contentType: "image/png", data })) - .catch(() => - delegate(url).then((image) => { - if (image) { - return sharp(image.data) - .png() - .toBuffer() - .then((png) => { - return fse - .writeFile(filename, png) - .then(() => ({ contentType: "image/png", data: png })); - }); - } else { - return undefined; - } - }) - ); - }; - -export const axiosImageFetcher = (url: string): Promise => - axios - .get(url, { - headers: BROWSER_HEADERS, - responseType: "arraybuffer", - }) - .then((res) => ({ - contentType: res.headers["content-type"], - data: Buffer.from(res.data, "binary"), - })) - .catch(() => undefined); - const AlbumQueryTypeToSubsonicType: Record = { alphabeticalByArtist: "alphabeticalByArtist", alphabeticalByName: "alphabeticalByName", @@ -893,6 +842,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { export class Subsonic implements MusicService { url: string; streamClientApplication: StreamClientApplication; + // todo: why is this in here? externalImageFetcher: ImageFetcher; constructor( diff --git a/src/utils.ts b/src/utils.ts index c886afc..33a4193 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,13 @@ +export const BROWSER_HEADERS = { + accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "accept-encoding": "gzip, deflate, br", + "accept-language": "en-GB,en;q=0.5", + "upgrade-insecure-requests": "1", + "user-agent": + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0", +}; + export function takeWithRepeats(things:T[], count: number) { const result = []; for(let i = 0; i < count; i++) { diff --git a/tests/images.test.ts b/tests/images.test.ts new file mode 100644 index 0000000..b23ed45 --- /dev/null +++ b/tests/images.test.ts @@ -0,0 +1,78 @@ + +import tmp from "tmp"; +import fse from "fs-extra"; +import path from "path"; +import { Md5 } from "ts-md5"; + +import sharp from "sharp"; +jest.mock("sharp"); + +import { cachingImageFetcher } from "../src/images"; + +describe("cachingImageFetcher", () => { + const delegate = jest.fn(); + const url = "http://test.example.com/someimage.jpg"; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + describe("when there is no image in the cache", () => { + it("should fetch the image from the source and then cache and return it", async () => { + const dir = tmp.dirSync(); + const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); + const jpgImage = Buffer.from("jpg-image", "utf-8"); + const pngImage = Buffer.from("png-image", "utf-8"); + + delegate.mockResolvedValue({ contentType: "image/jpeg", data: jpgImage }); + const png = jest.fn(); + (sharp as unknown as jest.Mock).mockReturnValue({ png }); + png.mockReturnValue({ + toBuffer: () => Promise.resolve(pngImage), + }); + + const result = await cachingImageFetcher(dir.name, delegate)(url); + + expect(result!.contentType).toEqual("image/png"); + expect(result!.data).toEqual(pngImage); + + expect(delegate).toHaveBeenCalledWith(url); + expect(fse.existsSync(cacheFile)).toEqual(true); + expect(fse.readFileSync(cacheFile)).toEqual(pngImage); + }); + }); + + describe("when the image is already in the cache", () => { + it("should fetch the image from the cache and return it", async () => { + const dir = tmp.dirSync(); + const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); + const data = Buffer.from("foobar2", "utf-8"); + + fse.writeFileSync(cacheFile, data); + + const result = await cachingImageFetcher(dir.name, delegate)(url); + + expect(result!.contentType).toEqual("image/png"); + expect(result!.data).toEqual(data); + + expect(delegate).not.toHaveBeenCalled(); + }); + }); + + describe("when the delegate returns undefined", () => { + it("should return undefined", async () => { + const dir = tmp.dirSync(); + const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); + + delegate.mockResolvedValue(undefined); + + const result = await cachingImageFetcher(dir.name, delegate)(url); + + expect(result).toBeUndefined(); + + expect(delegate).toHaveBeenCalledWith(url); + expect(fse.existsSync(cacheFile)).toEqual(false); + }); + }); +}); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 13ba320..3c0ec91 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -1,8 +1,6 @@ import { Md5 } from "ts-md5/dist/md5"; import { v4 as uuid } from "uuid"; -import tmp from "tmp"; -import fse from "fs-extra"; -import path from "path"; + import { pipe } from "fp-ts/lib/function"; import { option as O, taskEither as TE, task as T, either as E } from "fp-ts"; @@ -14,7 +12,6 @@ import { asGenre, appendMimeTypeToClientFor, asURLSearchParams, - cachingImageFetcher, asTrack, artistImageURN, images, @@ -29,8 +26,6 @@ import { import axios from "axios"; jest.mock("axios"); -import sharp from "sharp"; -jest.mock("sharp"); import randomstring from "randomstring"; jest.mock("randomstring"); @@ -172,74 +167,6 @@ describe("asURLSearchParams", () => { }); }); -describe("cachingImageFetcher", () => { - const delegate = jest.fn(); - const url = "http://test.example.com/someimage.jpg"; - - beforeEach(() => { - jest.clearAllMocks(); - jest.resetAllMocks(); - }); - - describe("when there is no image in the cache", () => { - it("should fetch the image from the source and then cache and return it", async () => { - const dir = tmp.dirSync(); - const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); - const jpgImage = Buffer.from("jpg-image", "utf-8"); - const pngImage = Buffer.from("png-image", "utf-8"); - - delegate.mockResolvedValue({ contentType: "image/jpeg", data: jpgImage }); - const png = jest.fn(); - (sharp as unknown as jest.Mock).mockReturnValue({ png }); - png.mockReturnValue({ - toBuffer: () => Promise.resolve(pngImage), - }); - - const result = await cachingImageFetcher(dir.name, delegate)(url); - - expect(result!.contentType).toEqual("image/png"); - expect(result!.data).toEqual(pngImage); - - expect(delegate).toHaveBeenCalledWith(url); - expect(fse.existsSync(cacheFile)).toEqual(true); - expect(fse.readFileSync(cacheFile)).toEqual(pngImage); - }); - }); - - describe("when the image is already in the cache", () => { - it("should fetch the image from the cache and return it", async () => { - const dir = tmp.dirSync(); - const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); - const data = Buffer.from("foobar2", "utf-8"); - - fse.writeFileSync(cacheFile, data); - - const result = await cachingImageFetcher(dir.name, delegate)(url); - - expect(result!.contentType).toEqual("image/png"); - expect(result!.data).toEqual(data); - - expect(delegate).not.toHaveBeenCalled(); - }); - }); - - describe("when the delegate returns undefined", () => { - it("should return undefined", async () => { - const dir = tmp.dirSync(); - const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); - - delegate.mockResolvedValue(undefined); - - const result = await cachingImageFetcher(dir.name, delegate)(url); - - expect(result).toBeUndefined(); - - expect(delegate).toHaveBeenCalledWith(url); - expect(fse.existsSync(cacheFile)).toEqual(false); - }); - }); -}); - const ok = (data: string | object) => ({ status: 200, data, From e37a09c2662f262363c960f64d321d2f78feaba6 Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 7 Jan 2022 12:17:01 +1100 Subject: [PATCH 06/18] ref --- src/subsonic.ts | 11 +--------- src/utils.ts | 14 ++++++++++++ tests/subsonic.test.ts | 48 +--------------------------------------- tests/utils.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 65 insertions(+), 58 deletions(-) diff --git a/src/subsonic.ts b/src/subsonic.ts index bfd82d3..c255e7b 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -30,6 +30,7 @@ import logger from "./logger"; import { assertSystem, BUrn } from "./burn"; import { artist } from "./smapi"; import { axiosImageFetcher, ImageFetcher } from "./images"; +import { asURLSearchParams } from "./utils"; export const t = (password: string, s: string) => @@ -331,16 +332,6 @@ export function appendMimeTypeToClientFor(mimeTypes: string[]) { mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob"; } -export const asURLSearchParams = (q: any) => { - const urlSearchParams = new URLSearchParams(); - Object.keys(q).forEach((k) => { - _.flatten([q[k]]).forEach((v) => { - urlSearchParams.append(k, `${v}`); - }); - }); - return urlSearchParams; -}; - const AlbumQueryTypeToSubsonicType: Record = { alphabeticalByArtist: "alphabeticalByArtist", alphabeticalByName: "alphabeticalByName", diff --git a/src/utils.ts b/src/utils.ts index 33a4193..4070e89 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +import { flatten } from "underscore"; + export const BROWSER_HEADERS = { accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", @@ -8,6 +10,18 @@ export const BROWSER_HEADERS = { "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0", }; + +export const asURLSearchParams = (q: any) => { + const urlSearchParams = new URLSearchParams(); + Object.keys(q).forEach((k) => { + flatten([q[k]]).forEach((v) => { + urlSearchParams.append(k, `${v}`); + }); + }); + return urlSearchParams; +}; + + export function takeWithRepeats(things:T[], count: number) { const result = []; for(let i = 0; i < count; i++) { diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 3c0ec91..8dddb0c 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -11,7 +11,6 @@ import { DODGY_IMAGE_NAME, asGenre, appendMimeTypeToClientFor, - asURLSearchParams, asTrack, artistImageURN, images, @@ -58,6 +57,7 @@ import { } from "./builders"; import { b64Encode } from "../src/b64"; import { BUrn } from "../src/burn"; +import { asURLSearchParams } from "../src/utils"; describe("t", () => { it("should be an md5 of the password and the salt", () => { @@ -121,52 +121,6 @@ describe("appendMimeTypeToUserAgentFor", () => { }); }); -describe("asURLSearchParams", () => { - describe("empty q", () => { - it("should return empty params", () => { - const q = {}; - const expected = new URLSearchParams(); - expect(asURLSearchParams(q)).toEqual(expected); - }); - }); - - describe("singular params", () => { - it("should append each", () => { - const q = { - a: 1, - b: "bee", - c: false, - d: true, - }; - const expected = new URLSearchParams(); - expected.append("a", "1"); - expected.append("b", "bee"); - expected.append("c", "false"); - expected.append("d", "true"); - - expect(asURLSearchParams(q)).toEqual(expected); - }); - }); - - describe("list params", () => { - it("should append each", () => { - const q = { - a: [1, "two", false, true], - b: "yippee", - }; - - const expected = new URLSearchParams(); - expected.append("a", "1"); - expected.append("a", "two"); - expected.append("a", "false"); - expected.append("a", "true"); - expected.append("b", "yippee"); - - expect(asURLSearchParams(q)).toEqual(expected); - }); - }); -}); - const ok = (data: string | object) => ({ status: 200, data, diff --git a/tests/utils.test.ts b/tests/utils.test.ts index ce0d5f3..f494703 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,4 +1,52 @@ -import { takeWithRepeats } from "../src/utils"; +import { asURLSearchParams, takeWithRepeats } from "../src/utils"; + +describe("asURLSearchParams", () => { + describe("empty q", () => { + it("should return empty params", () => { + const q = {}; + const expected = new URLSearchParams(); + expect(asURLSearchParams(q)).toEqual(expected); + }); + }); + + describe("singular params", () => { + it("should append each", () => { + const q = { + a: 1, + b: "bee", + c: false, + d: true, + }; + const expected = new URLSearchParams(); + expected.append("a", "1"); + expected.append("b", "bee"); + expected.append("c", "false"); + expected.append("d", "true"); + + expect(asURLSearchParams(q)).toEqual(expected); + }); + }); + + describe("list params", () => { + it("should append each", () => { + const q = { + a: [1, "two", false, true], + b: "yippee", + }; + + const expected = new URLSearchParams(); + expected.append("a", "1"); + expected.append("a", "two"); + expected.append("a", "false"); + expected.append("a", "true"); + expected.append("b", "yippee"); + + expect(asURLSearchParams(q)).toEqual(expected); + }); + }); +}); + + describe("takeWithRepeat", () => { describe("when there is nothing in the input", () => { From 50cb5b2550c7b2a12f50e795a3ea4013295e4dae Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 7 Jan 2022 13:26:19 +1100 Subject: [PATCH 07/18] ref --- src/subsonic.ts | 754 +----- src/subsonic/generic.ts | 728 ++++++ tests/builders.ts | 2 +- tests/subsonic.test.ts | 4463 +------------------------------- tests/subsonic/generic.test.ts | 4390 +++++++++++++++++++++++++++++++ 5 files changed, 5147 insertions(+), 5190 deletions(-) create mode 100644 src/subsonic/generic.ts create mode 100644 tests/subsonic/generic.test.ts diff --git a/src/subsonic.ts b/src/subsonic.ts index c255e7b..1ea4765 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -1,36 +1,25 @@ -import { option as O, taskEither as TE } from "fp-ts"; -import * as A from "fp-ts/Array"; -import { ordString } from "fp-ts/lib/Ord"; +import { taskEither as TE } from "fp-ts"; import { pipe } from "fp-ts/lib/function"; import { Md5 } from "ts-md5/dist/md5"; import { Credentials, MusicService, - Album, Result, - slice2, - AlbumQuery, ArtistQuery, MusicLibrary, - AlbumSummary, - Genre, Track, - Rating, - AlbumQueryType, - Artist, AuthFailure, Sortable, + ArtistSummary, } from "./music_service"; import _ from "underscore"; import axios, { AxiosRequestConfig } from "axios"; import randomstring from "randomstring"; import { b64Encode, b64Decode } from "./b64"; -import logger from "./logger"; -import { assertSystem, BUrn } from "./burn"; -import { artist } from "./smapi"; import { axiosImageFetcher, ImageFetcher } from "./images"; import { asURLSearchParams } from "./utils"; +import { artistImageURN, SubsonicGenericMusicLibrary } from "./subsonic/generic"; export const t = (password: string, s: string) => @@ -44,154 +33,26 @@ export const t_and_s = (password: string) => { }; }; +// todo: this is an ND thing export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png"; -export const isValidImage = (url: string | undefined) => - url != undefined && !url.endsWith(DODGY_IMAGE_NAME); + type SubsonicEnvelope = { "subsonic-response": SubsonicResponse; }; -type SubsonicResponse = { +export type SubsonicResponse = { status: string; }; -type album = { - id: string; - name: string; - artist: string | undefined; - artistId: string | undefined; - coverArt: string | undefined; - genre: string | undefined; - year: string | undefined; -}; - -type artist = { - id: string; - name: string; - albumCount: number; - artistImageUrl: string | undefined; -}; - -type GetArtistsResponse = SubsonicResponse & { - artists: { - index: { - artist: artist[]; - name: string; - }[]; - }; -}; - -type GetAlbumListResponse = SubsonicResponse & { - albumList2: { - album: album[]; - }; -}; - -type genre = { - songCount: number; - albumCount: number; - value: string; -}; - -type GetGenresResponse = SubsonicResponse & { - genres: { - genre: genre[]; - }; -}; - -type SubsonicError = SubsonicResponse & { +export type SubsonicError = SubsonicResponse & { error: { code: string; message: string; }; }; -export type images = { - smallImageUrl: string | undefined; - mediumImageUrl: string | undefined; - largeImageUrl: string | undefined; -}; - -type artistInfo = images & { - biography: string | undefined; - musicBrainzId: string | undefined; - lastFmUrl: string | undefined; - similarArtist: artist[]; -}; - -type ArtistSummary = IdName & { - image: BUrn | undefined; -}; - -type GetArtistInfoResponse = SubsonicResponse & { - artistInfo2: artistInfo; -}; - -type GetArtistResponse = SubsonicResponse & { - artist: artist & { - album: album[]; - }; -}; - -export type song = { - id: string; - parent: string | undefined; - title: string; - album: string | undefined; - albumId: string | undefined; - artist: string | undefined; - artistId: string | undefined; - track: number | undefined; - year: string | undefined; - genre: string | undefined; - coverArt: string | undefined; - created: string | undefined; - duration: number | undefined; - bitRate: number | undefined; - suffix: string | undefined; - contentType: string | undefined; - type: string | undefined; - userRating: number | undefined; - starred: string | undefined; -}; - -type GetAlbumResponse = { - album: album & { - song: song[]; - }; -}; - -type playlist = { - id: string; - name: string; -}; - -type GetPlaylistResponse = { - playlist: { - id: string; - name: string; - entry: song[]; - }; -}; - -type GetPlaylistsResponse = { - playlists: { playlist: playlist[] }; -}; - -type GetSimilarSongsResponse = { - similarSongs2: { song: song[] }; -}; - -type GetTopSongsResponse = { - topSongs: { song: song[] }; -}; - -type GetSongResponse = { - song: song; -}; - export type PingResponse = { status: string; version: string; @@ -199,14 +60,6 @@ export type PingResponse = { serverVersion: string; }; -type Search3Response = SubsonicResponse & { - searchResult3: { - artist: artist[]; - album: album[]; - song: song[]; - }; -}; - export function isError( subsonicResponse: SubsonicResponse ): subsonicResponse is SubsonicError { @@ -220,109 +73,12 @@ export type NDArtist = { largeImageUrl: string | undefined; }; -type IdName = { - id: string; - name: string; -}; - -const coverArtURN = (coverArt: string | undefined): BUrn | undefined => - pipe( - coverArt, - O.fromNullable, - O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })), - 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; - artistImageURL: string | undefined; - }> -): BUrn | undefined => { - const deets = { - artistId: undefined, - artistImageURL: undefined, - ...spec, - }; - if (deets.artistImageURL && isValidImage(deets.artistImageURL)) { - return { - system: "external", - resource: deets.artistImageURL, - }; - } else if (artistIsInLibrary(deets.artistId)) { - return { - system: "subsonic", - resource: `art:${deets.artistId!}`, - }; - } else { - return undefined; - } -}; - -export const asTrack = (album: Album, song: song): Track => ({ - id: song.id, - name: song.title, - mimeType: song.contentType!, - duration: song.duration || 0, - number: song.track || 0, - genre: maybeAsGenre(song.genre), - coverArt: coverArtURN(song.coverArt), - album, - artist: { - id: song.artistId, - name: song.artist ? song.artist : "?", - image: song.artistId - ? artistImageURN({ artistId: song.artistId }) - : undefined, - }, - rating: { - love: song.starred != undefined, - stars: - song.userRating && song.userRating <= 5 && song.userRating >= 0 - ? song.userRating - : 0, - }, -}); - -const asAlbum = (album: album): Album => ({ - id: album.id, - name: album.name, - year: album.year, - genre: maybeAsGenre(album.genre), - artistId: album.artistId, - artistName: album.artist, - coverArt: coverArtURN(album.coverArt), -}); -export const asGenre = (genreName: string) => ({ - id: b64Encode(genreName), - name: genreName, -}); - -const maybeAsGenre = (genreName: string | undefined): Genre | undefined => - pipe( - genreName, - O.fromNullable, - O.map(asGenre), - O.getOrElseW(() => undefined) - ); export type StreamClientApplication = (track: Track) => string; -const DEFAULT_CLIENT_APPLICATION = "bonob"; -const USER_AGENT = "bonob"; +export const DEFAULT_CLIENT_APPLICATION = "bonob"; +export const USER_AGENT = "bonob"; export const DEFAULT: StreamClientApplication = (_: Track) => DEFAULT_CLIENT_APPLICATION; @@ -332,20 +88,6 @@ export function appendMimeTypeToClientFor(mimeTypes: string[]) { mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob"; } -const AlbumQueryTypeToSubsonicType: Record = { - alphabeticalByArtist: "alphabeticalByArtist", - alphabeticalByName: "alphabeticalByName", - byGenre: "byGenre", - random: "random", - recentlyPlayed: "recent", - mostPlayed: "frequent", - recentlyAdded: "newest", - favourited: "starred", - starred: "highest", -}; - -const artistIsInLibrary = (artistId: string | undefined) => - artistId != undefined && artistId != "-1"; export type SubsonicCredentials = Credentials & { type: string; @@ -357,478 +99,24 @@ export const asToken = (credentials: SubsonicCredentials) => export const parseToken = (token: string): SubsonicCredentials => JSON.parse(b64Decode(token)); -interface SubsonicMusicLibrary extends MusicLibrary { +export interface SubsonicMusicLibrary extends MusicLibrary { flavour(): string; bearerToken( credentials: Credentials ): TE.TaskEither; } -export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { - subsonic: Subsonic; - credentials: SubsonicCredentials; - - constructor(subsonic: Subsonic, credentials: SubsonicCredentials) { - this.subsonic = subsonic; - this.credentials = credentials; - } - - flavour = () => "subsonic"; - - bearerToken = (_: Credentials) => TE.right(undefined); - - artists = (q: ArtistQuery): Promise> => - this.getArtists() - .then(slice2(q)) - .then(([page, total]) => ({ - total, - results: page.map((it) => ({ - id: it.id, - name: it.name, - sortName: it.name, - image: it.image, - })), - })); - - artist = async (id: string): Promise => - this.getArtistWithInfo(id); - - albums = async (q: AlbumQuery): Promise> => - this.getAlbumList2(q); - - album = (id: string): Promise => this.getAlbum(id); - - genres = () => - this.subsonic - .getJSON(this.credentials, "/rest/getGenres") - .then((it) => - pipe( - it.genres.genre || [], - A.filter((it) => it.albumCount > 0), - A.map((it) => it.value), - A.sort(ordString), - A.map((it) => ({ id: b64Encode(it), name: it })) - ) - ); - - tracks = (albumId: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getAlbum", { - id: albumId, - }) - .then((it) => it.album) - .then((album) => - (album.song || []).map((song) => asTrack(asAlbum(album), song)) - ); - - track = (trackId: string) => this.getTrack(trackId); - - rate = (trackId: string, rating: Rating) => - Promise.resolve(true) - .then(() => { - if (rating.stars >= 0 && rating.stars <= 5) { - return this.getTrack(trackId); - } else { - throw `Invalid rating.stars value of ${rating.stars}`; - } - }) - .then((track) => { - const thingsToUpdate = []; - if (track.rating.love != rating.love) { - thingsToUpdate.push( - this.subsonic.getJSON( - this.credentials, - `/rest/${rating.love ? "star" : "unstar"}`, - { - id: trackId, - } - ) - ); - } - if (track.rating.stars != rating.stars) { - thingsToUpdate.push( - this.subsonic.getJSON(this.credentials, `/rest/setRating`, { - id: trackId, - rating: rating.stars, - }) - ); - } - return Promise.all(thingsToUpdate); - }) - .then(() => true) - .catch(() => false); - - stream = async ({ - trackId, - range, - }: { - trackId: string; - range: string | undefined; - }) => - this.getTrack(trackId).then((track) => - this.subsonic - .get( - this.credentials, - `/rest/stream`, - { - id: trackId, - c: this.subsonic.streamClientApplication(track), - }, - { - headers: pipe( - range, - O.fromNullable, - O.map((range) => ({ - "User-Agent": USER_AGENT, - Range: range, - })), - O.getOrElse(() => ({ - "User-Agent": USER_AGENT, - })) - ), - responseType: "stream", - } - ) - .then((res) => ({ - status: res.status, - headers: { - "content-type": res.headers["content-type"], - "content-length": res.headers["content-length"], - "content-range": res.headers["content-range"], - "accept-ranges": res.headers["accept-ranges"], - }, - stream: res.data, - })) - ); - - coverArt = async (coverArtURN: BUrn, size?: number) => - Promise.resolve(coverArtURN) - .then((it) => assertSystem(it, "subsonic")) - .then((it) => it.resource.split(":")[1]!) - .then((it) => this.getCoverArt(this.credentials, it, size)) - .then((res) => ({ - contentType: res.headers["content-type"], - data: Buffer.from(res.data, "binary"), - })) - .catch((e) => { - logger.error(`Failed getting coverArt for urn:'${coverArtURN}': ${e}`); - return undefined; - }); - - scrobble = async (id: string) => - this.subsonic - .getJSON(this.credentials, `/rest/scrobble`, { - id, - submission: true, - }) - .then((_) => true) - .catch(() => false); - - nowPlaying = async (id: string) => - this.subsonic - .getJSON(this.credentials, `/rest/scrobble`, { - id, - submission: false, - }) - .then((_) => true) - .catch(() => false); - - searchArtists = async (query: string) => - this.search3({ query, artistCount: 20 }).then( - ({ artists }) => - artists.map((artist) => ({ - id: artist.id, - name: artist.name, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: artist.artistImageUrl, - }), - })) - ); - - searchAlbums = async (query: string) => - this.search3({ query, albumCount: 20 }).then( - ({ albums }) => this.toAlbumSummary(albums) - ); - - searchTracks = async (query: string) => - this.search3({ query, songCount: 20 }).then(({ songs }) => - Promise.all(songs.map((it) => this.getTrack(it.id))) - ); - - playlists = async () => - this.subsonic - .getJSON(this.credentials, "/rest/getPlaylists") - .then((it) => it.playlists.playlist || []) - .then((playlists) => - playlists.map((it) => ({ id: it.id, name: it.name })) - ); - - playlist = async (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getPlaylist", { - id, - }) - .then((it) => it.playlist) - .then((playlist) => { - let trackNumber = 1; - return { - id: playlist.id, - name: playlist.name, - entries: (playlist.entry || []).map((entry) => ({ - ...asTrack( - { - id: entry.albumId!, - name: entry.album!, - year: entry.year, - genre: maybeAsGenre(entry.genre), - artistName: entry.artist, - artistId: entry.artistId, - coverArt: coverArtURN(entry.coverArt), - }, - entry - ), - number: trackNumber++, - })), - }; - }); - - createPlaylist = async (name: string) => - this.subsonic - .getJSON(this.credentials, "/rest/createPlaylist", { - name, - }) - .then((it) => it.playlist) - .then((it) => ({ id: it.id, name: it.name })); - - deletePlaylist = async (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/deletePlaylist", { - id, - }) - .then((_) => true); - - addToPlaylist = async (playlistId: string, trackId: string) => - this.subsonic - .getJSON(this.credentials, "/rest/updatePlaylist", { - playlistId, - songIdToAdd: trackId, - }) - .then((_) => true); - - removeFromPlaylist = async (playlistId: string, indicies: number[]) => - this.subsonic - .getJSON(this.credentials, "/rest/updatePlaylist", { - playlistId, - songIndexToRemove: indicies, - }) - .then((_) => true); - - similarSongs = async (id: string) => - this.subsonic - .getJSON( - this.credentials, - "/rest/getSimilarSongs2", - { id, count: 50 } - ) - .then((it) => it.similarSongs2.song || []) - .then((songs) => - Promise.all( - songs.map((song) => - this.getAlbum(song.albumId!).then((album) => - asTrack(album, song) - ) - ) - ) - ); - - topSongs = async (artistId: string) => - this.getArtist(artistId).then(({ name }) => - this.subsonic - .getJSON(this.credentials, "/rest/getTopSongs", { - artist: name, - count: 50, - }) - .then((it) => it.topSongs.song || []) - .then((songs) => - Promise.all( - songs.map((song) => - this.getAlbum(song.albumId!).then((album) => - asTrack(album, song) - ) - ) - ) - ) - ); - - private getArtists = (): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> => - this.subsonic - .getJSON(this.credentials, "/rest/getArtists") - .then((it) => (it.artists.index || []).flatMap((it) => it.artist || [])) - .then((artists) => - artists.map((artist) => ({ - id: `${artist.id}`, - name: artist.name, - albumCount: artist.albumCount, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: artist.artistImageUrl, - }), - })) - ); - - private getArtistInfo = ( - id: string - ): Promise<{ - similarArtist: (ArtistSummary & { inLibrary: boolean })[]; - images: { - s: string | undefined; - m: string | undefined; - l: string | undefined; - }; - }> => - this.subsonic - .getJSON(this.credentials, "/rest/getArtistInfo2", { - id, - count: 50, - includeNotPresent: true, - }) - .then((it) => it.artistInfo2) - .then((it) => ({ - images: { - s: it.smallImageUrl, - m: it.mediumImageUrl, - l: it.largeImageUrl, - }, - similarArtist: (it.similarArtist || []).map((artist) => ({ - id: `${artist.id}`, - name: artist.name, - inLibrary: artistIsInLibrary(artist.id), - image: artistImageURN({ - artistId: artist.id, - artistImageURL: artist.artistImageUrl, - }), - })), - })); - - private getAlbum = (id: string): Promise => - this.subsonic - .getJSON(this.credentials, "/rest/getAlbum", { id }) - .then((it) => it.album) - .then((album) => ({ - id: album.id, - name: album.name, - year: album.year, - genre: maybeAsGenre(album.genre), - artistId: album.artistId, - artistName: album.artist, - coverArt: coverArtURN(album.coverArt), - })); - - private getArtist = ( - id: string - ): Promise< - IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] } - > => - this.subsonic - .getJSON(this.credentials, "/rest/getArtist", { - id, - }) - .then((it) => it.artist) - .then((it) => ({ - id: it.id, - name: it.name, - artistImageUrl: it.artistImageUrl, - albums: this.toAlbumSummary(it.album || []), - })); - - private getArtistWithInfo = (id: string) => - Promise.all([ - this.getArtist(id), - this.getArtistInfo(id), - ]).then(([artist, artistInfo]) => ({ - id: artist.id, - name: artist.name, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: [ - artist.artistImageUrl, - artistInfo.images.l, - artistInfo.images.m, - artistInfo.images.s, - ].find(isValidImage), - }), - albums: artist.albums, - similarArtists: artistInfo.similarArtist, - })); - - private getCoverArt = (credentials: Credentials, id: string, size?: number) => - this.subsonic.get( - credentials, - "/rest/getCoverArt", - size ? { id, size } : { id }, - { - headers: { "User-Agent": "bonob" }, - responseType: "arraybuffer", - } - ); - - private getTrack = (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getSong", { - id, - }) - .then((it) => it.song) - .then((song) => - this.getAlbum(song.albumId!).then((album) => - asTrack(album, song) - ) - ); - - private toAlbumSummary = (albumList: album[]): AlbumSummary[] => - albumList.map((album) => ({ - id: album.id, - name: album.name, - year: album.year, - genre: maybeAsGenre(album.genre), - artistId: album.artistId, - artistName: album.artist, - coverArt: coverArtURN(album.coverArt), - })); - - private search3 = (q: any) => - this.subsonic - .getJSON(this.credentials, "/rest/search3", { - artistCount: 0, - albumCount: 0, - songCount: 0, - ...q, - }) - .then((it) => ({ - artists: it.searchResult3.artist || [], - albums: it.searchResult3.album || [], - songs: it.searchResult3.song || [], - })); - - private getAlbumList2 = (q: AlbumQuery) => - Promise.all([ - this.getArtists().then((it) => - _.inject(it, (total, artist) => total + artist.albumCount, 0) - ), - this.subsonic - .getJSON(this.credentials, "/rest/getAlbumList2", { - type: AlbumQueryTypeToSubsonicType[q.type], - ...(q.genre ? { genre: b64Decode(q.genre) } : {}), - size: 500, - offset: q._index, - }) - .then((response) => response.albumList2.album || []) - .then(this.toAlbumSummary), - ]).then(([total, albums]) => ({ - results: albums.slice(0, q._count), - total: albums.length == 500 ? total : (q._index || 0) + albums.length, - })); -} +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 class Subsonic implements MusicService { url: string; diff --git a/src/subsonic/generic.ts b/src/subsonic/generic.ts new file mode 100644 index 0000000..8ba8e18 --- /dev/null +++ b/src/subsonic/generic.ts @@ -0,0 +1,728 @@ +import { option as O, taskEither as TE } from "fp-ts"; +import * as A from "fp-ts/Array"; +import { pipe } from "fp-ts/lib/function"; +import { ordString } from "fp-ts/lib/Ord"; +import { inject } from 'underscore'; + +import logger from "../logger"; +import { b64Decode, b64Encode } from "../b64"; +import { assertSystem, BUrn } from "../burn"; + +import { Album, AlbumQuery, AlbumQueryType, AlbumSummary, Artist, ArtistQuery, Credentials, Genre, Rating, Result, slice2, Sortable, Track } from "../music_service"; +import Subsonic, { DODGY_IMAGE_NAME, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, USER_AGENT } from "../subsonic"; + + +type album = { + id: string; + name: string; + artist: string | undefined; + artistId: string | undefined; + coverArt: string | undefined; + genre: string | undefined; + year: string | undefined; +}; + +type artist = { + id: string; + name: string; + albumCount: number; + artistImageUrl: string | undefined; +}; + +type GetArtistsResponse = SubsonicResponse & { + artists: { + index: { + artist: artist[]; + name: string; + }[]; + }; +}; + +type GetAlbumListResponse = SubsonicResponse & { + albumList2: { + album: album[]; + }; +}; + +type genre = { + songCount: number; + albumCount: number; + value: string; +}; + +type GetGenresResponse = SubsonicResponse & { + genres: { + genre: genre[]; + }; +}; + + +type GetArtistInfoResponse = SubsonicResponse & { + artistInfo2: artistInfo; +}; + +type GetArtistResponse = SubsonicResponse & { + artist: artist & { + album: album[]; + }; +}; + + +export type images = { + smallImageUrl: string | undefined; + mediumImageUrl: string | undefined; + largeImageUrl: string | undefined; +}; + +type artistInfo = images & { + biography: string | undefined; + musicBrainzId: string | undefined; + lastFmUrl: string | undefined; + similarArtist: artist[]; +}; + +type IdName = { + id: string; + name: string; +}; + +type ArtistSummary = IdName & { + image: BUrn | undefined; +}; + +export type song = { + id: string; + parent: string | undefined; + title: string; + album: string | undefined; + albumId: string | undefined; + artist: string | undefined; + artistId: string | undefined; + track: number | undefined; + year: string | undefined; + genre: string | undefined; + coverArt: string | undefined; + created: string | undefined; + duration: number | undefined; + bitRate: number | undefined; + suffix: string | undefined; + contentType: string | undefined; + type: string | undefined; + userRating: number | undefined; + starred: string | undefined; +}; + +type GetAlbumResponse = { + album: album & { + song: song[]; + }; +}; + +type playlist = { + id: string; + name: string; +}; + +type GetPlaylistResponse = { + playlist: { + id: string; + name: string; + entry: song[]; + }; +}; + +type GetPlaylistsResponse = { + playlists: { playlist: playlist[] }; +}; + +type GetSimilarSongsResponse = { + similarSongs2: { song: song[] }; +}; + +type GetTopSongsResponse = { + topSongs: { song: song[] }; +}; + +type GetSongResponse = { + song: song; +}; + + +type Search3Response = SubsonicResponse & { + searchResult3: { + artist: artist[]; + album: album[]; + song: song[]; + }; +}; + +const AlbumQueryTypeToSubsonicType: Record = { + alphabeticalByArtist: "alphabeticalByArtist", + alphabeticalByName: "alphabeticalByName", + byGenre: "byGenre", + random: "random", + recentlyPlayed: "recent", + mostPlayed: "frequent", + recentlyAdded: "newest", + favourited: "starred", + starred: "highest", +}; + + +export const isValidImage = (url: string | undefined) => + url != undefined && !url.endsWith(DODGY_IMAGE_NAME); + +const artistIsInLibrary = (artistId: string | undefined) => + artistId != undefined && artistId != "-1"; + + +const coverArtURN = (coverArt: string | undefined): BUrn | undefined => + pipe( + coverArt, + O.fromNullable, + O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })), + O.getOrElseW(() => undefined) + ); + + + // todo: is this the right place for this?? +export const artistImageURN = ( + spec: Partial<{ + artistId: string | undefined; + artistImageURL: string | undefined; + }> +): BUrn | undefined => { + const deets = { + artistId: undefined, + artistImageURL: undefined, + ...spec, + }; + if (deets.artistImageURL && isValidImage(deets.artistImageURL)) { + return { + system: "external", + resource: deets.artistImageURL, + }; + } else if (artistIsInLibrary(deets.artistId)) { + return { + system: "subsonic", + resource: `art:${deets.artistId!}`, + }; + } else { + return undefined; + } +}; + +export const asTrack = (album: Album, song: song): Track => ({ + id: song.id, + name: song.title, + mimeType: song.contentType!, + duration: song.duration || 0, + number: song.track || 0, + genre: maybeAsGenre(song.genre), + coverArt: coverArtURN(song.coverArt), + album, + artist: { + id: song.artistId, + name: song.artist ? song.artist : "?", + image: song.artistId + ? artistImageURN({ artistId: song.artistId }) + : undefined, + }, + rating: { + love: song.starred != undefined, + stars: + song.userRating && song.userRating <= 5 && song.userRating >= 0 + ? song.userRating + : 0, + }, +}); + +const asAlbum = (album: album): Album => ({ + id: album.id, + name: album.name, + year: album.year, + genre: maybeAsGenre(album.genre), + artistId: album.artistId, + artistName: album.artist, + coverArt: coverArtURN(album.coverArt), +}); + +export const asGenre = (genreName: string) => ({ + id: b64Encode(genreName), + name: genreName, +}); + +const maybeAsGenre = (genreName: string | undefined): Genre | undefined => + pipe( + genreName, + O.fromNullable, + O.map(asGenre), + O.getOrElseW(() => undefined) + ); + + +export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { + subsonic: Subsonic; + credentials: SubsonicCredentials; + + constructor(subsonic: Subsonic, credentials: SubsonicCredentials) { + this.subsonic = subsonic; + this.credentials = credentials; + } + + flavour = () => "subsonic"; + + bearerToken = (_: Credentials) => TE.right(undefined); + + artists = (q: ArtistQuery): Promise> => + this.getArtists() + .then(slice2(q)) + .then(([page, total]) => ({ + total, + results: page.map((it) => ({ + id: it.id, + name: it.name, + sortName: it.name, + image: it.image, + })), + })); + + artist = async (id: string): Promise => + this.getArtistWithInfo(id); + + albums = async (q: AlbumQuery): Promise> => + this.getAlbumList2(q); + + album = (id: string): Promise => this.getAlbum(id); + + genres = () => + this.subsonic + .getJSON(this.credentials, "/rest/getGenres") + .then((it) => + pipe( + it.genres.genre || [], + A.filter((it) => it.albumCount > 0), + A.map((it) => it.value), + A.sort(ordString), + A.map((it) => ({ id: b64Encode(it), name: it })) + ) + ); + + tracks = (albumId: string) => + this.subsonic + .getJSON(this.credentials, "/rest/getAlbum", { + id: albumId, + }) + .then((it) => it.album) + .then((album) => + (album.song || []).map((song) => asTrack(asAlbum(album), song)) + ); + + track = (trackId: string) => this.getTrack(trackId); + + rate = (trackId: string, rating: Rating) => + Promise.resolve(true) + .then(() => { + if (rating.stars >= 0 && rating.stars <= 5) { + return this.getTrack(trackId); + } else { + throw `Invalid rating.stars value of ${rating.stars}`; + } + }) + .then((track) => { + const thingsToUpdate = []; + if (track.rating.love != rating.love) { + thingsToUpdate.push( + this.subsonic.getJSON( + this.credentials, + `/rest/${rating.love ? "star" : "unstar"}`, + { + id: trackId, + } + ) + ); + } + if (track.rating.stars != rating.stars) { + thingsToUpdate.push( + this.subsonic.getJSON(this.credentials, `/rest/setRating`, { + id: trackId, + rating: rating.stars, + }) + ); + } + return Promise.all(thingsToUpdate); + }) + .then(() => true) + .catch(() => false); + + stream = async ({ + trackId, + range, + }: { + trackId: string; + range: string | undefined; + }) => + this.getTrack(trackId).then((track) => + this.subsonic + .get( + this.credentials, + `/rest/stream`, + { + id: trackId, + c: this.subsonic.streamClientApplication(track), + }, + { + headers: pipe( + range, + O.fromNullable, + O.map((range) => ({ + "User-Agent": USER_AGENT, + Range: range, + })), + O.getOrElse(() => ({ + "User-Agent": USER_AGENT, + })) + ), + responseType: "stream", + } + ) + .then((res) => ({ + status: res.status, + headers: { + "content-type": res.headers["content-type"], + "content-length": res.headers["content-length"], + "content-range": res.headers["content-range"], + "accept-ranges": res.headers["accept-ranges"], + }, + stream: res.data, + })) + ); + + coverArt = async (coverArtURN: BUrn, size?: number) => + Promise.resolve(coverArtURN) + .then((it) => assertSystem(it, "subsonic")) + .then((it) => it.resource.split(":")[1]!) + .then((it) => this.getCoverArt(this.credentials, it, size)) + .then((res) => ({ + contentType: res.headers["content-type"], + data: Buffer.from(res.data, "binary"), + })) + .catch((e) => { + logger.error(`Failed getting coverArt for urn:'${coverArtURN}': ${e}`); + return undefined; + }); + + scrobble = async (id: string) => + this.subsonic + .getJSON(this.credentials, `/rest/scrobble`, { + id, + submission: true, + }) + .then((_) => true) + .catch(() => false); + + nowPlaying = async (id: string) => + this.subsonic + .getJSON(this.credentials, `/rest/scrobble`, { + id, + submission: false, + }) + .then((_) => true) + .catch(() => false); + + searchArtists = async (query: string) => + this.search3({ query, artistCount: 20 }).then( + ({ artists }) => + artists.map((artist) => ({ + id: artist.id, + name: artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), + })) + ); + + searchAlbums = async (query: string) => + this.search3({ query, albumCount: 20 }).then( + ({ albums }) => this.toAlbumSummary(albums) + ); + + searchTracks = async (query: string) => + this.search3({ query, songCount: 20 }).then(({ songs }) => + Promise.all(songs.map((it) => this.getTrack(it.id))) + ); + + playlists = async () => + this.subsonic + .getJSON(this.credentials, "/rest/getPlaylists") + .then((it) => it.playlists.playlist || []) + .then((playlists) => + playlists.map((it) => ({ id: it.id, name: it.name })) + ); + + playlist = async (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/getPlaylist", { + id, + }) + .then((it) => it.playlist) + .then((playlist) => { + let trackNumber = 1; + return { + id: playlist.id, + name: playlist.name, + entries: (playlist.entry || []).map((entry) => ({ + ...asTrack( + { + id: entry.albumId!, + name: entry.album!, + year: entry.year, + genre: maybeAsGenre(entry.genre), + artistName: entry.artist, + artistId: entry.artistId, + coverArt: coverArtURN(entry.coverArt), + }, + entry + ), + number: trackNumber++, + })), + }; + }); + + createPlaylist = async (name: string) => + this.subsonic + .getJSON(this.credentials, "/rest/createPlaylist", { + name, + }) + .then((it) => it.playlist) + .then((it) => ({ id: it.id, name: it.name })); + + deletePlaylist = async (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/deletePlaylist", { + id, + }) + .then((_) => true); + + addToPlaylist = async (playlistId: string, trackId: string) => + this.subsonic + .getJSON(this.credentials, "/rest/updatePlaylist", { + playlistId, + songIdToAdd: trackId, + }) + .then((_) => true); + + removeFromPlaylist = async (playlistId: string, indicies: number[]) => + this.subsonic + .getJSON(this.credentials, "/rest/updatePlaylist", { + playlistId, + songIndexToRemove: indicies, + }) + .then((_) => true); + + similarSongs = async (id: string) => + this.subsonic + .getJSON( + this.credentials, + "/rest/getSimilarSongs2", + { id, count: 50 } + ) + .then((it) => it.similarSongs2.song || []) + .then((songs) => + Promise.all( + songs.map((song) => + this.getAlbum(song.albumId!).then((album) => + asTrack(album, song) + ) + ) + ) + ); + + topSongs = async (artistId: string) => + this.getArtist(artistId).then(({ name }) => + this.subsonic + .getJSON(this.credentials, "/rest/getTopSongs", { + artist: name, + count: 50, + }) + .then((it) => it.topSongs.song || []) + .then((songs) => + Promise.all( + songs.map((song) => + this.getAlbum(song.albumId!).then((album) => + asTrack(album, song) + ) + ) + ) + ) + ); + + private getArtists = (): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> => + this.subsonic + .getJSON(this.credentials, "/rest/getArtists") + .then((it) => (it.artists.index || []).flatMap((it) => it.artist || [])) + .then((artists) => + artists.map((artist) => ({ + id: `${artist.id}`, + name: artist.name, + albumCount: artist.albumCount, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), + })) + ); + + private getArtistInfo = ( + id: string + ): Promise<{ + similarArtist: (ArtistSummary & { inLibrary: boolean })[]; + images: { + s: string | undefined; + m: string | undefined; + l: string | undefined; + }; + }> => + this.subsonic + .getJSON(this.credentials, "/rest/getArtistInfo2", { + id, + count: 50, + includeNotPresent: true, + }) + .then((it) => it.artistInfo2) + .then((it) => ({ + images: { + s: it.smallImageUrl, + m: it.mediumImageUrl, + l: it.largeImageUrl, + }, + similarArtist: (it.similarArtist || []).map((artist) => ({ + id: `${artist.id}`, + name: artist.name, + inLibrary: artistIsInLibrary(artist.id), + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), + })), + })); + + private getAlbum = (id: string): Promise => + this.subsonic + .getJSON(this.credentials, "/rest/getAlbum", { id }) + .then((it) => it.album) + .then((album) => ({ + id: album.id, + name: album.name, + year: album.year, + genre: maybeAsGenre(album.genre), + artistId: album.artistId, + artistName: album.artist, + coverArt: coverArtURN(album.coverArt), + })); + + private getArtist = ( + id: string + ): Promise< + IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] } + > => + this.subsonic + .getJSON(this.credentials, "/rest/getArtist", { + id, + }) + .then((it) => it.artist) + .then((it) => ({ + id: it.id, + name: it.name, + artistImageUrl: it.artistImageUrl, + albums: this.toAlbumSummary(it.album || []), + })); + + private getArtistWithInfo = (id: string) => + Promise.all([ + this.getArtist(id), + this.getArtistInfo(id), + ]).then(([artist, artistInfo]) => ({ + id: artist.id, + name: artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: [ + artist.artistImageUrl, + artistInfo.images.l, + artistInfo.images.m, + artistInfo.images.s, + ].find(isValidImage), + }), + albums: artist.albums, + similarArtists: artistInfo.similarArtist, + })); + + private getCoverArt = (credentials: Credentials, id: string, size?: number) => + this.subsonic.get( + credentials, + "/rest/getCoverArt", + size ? { id, size } : { id }, + { + headers: { "User-Agent": "bonob" }, + responseType: "arraybuffer", + } + ); + + private getTrack = (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/getSong", { + id, + }) + .then((it) => it.song) + .then((song) => + this.getAlbum(song.albumId!).then((album) => + asTrack(album, song) + ) + ); + + private toAlbumSummary = (albumList: album[]): AlbumSummary[] => + albumList.map((album) => ({ + id: album.id, + name: album.name, + year: album.year, + genre: maybeAsGenre(album.genre), + artistId: album.artistId, + artistName: album.artist, + coverArt: coverArtURN(album.coverArt), + })); + + private search3 = (q: any) => + this.subsonic + .getJSON(this.credentials, "/rest/search3", { + artistCount: 0, + albumCount: 0, + songCount: 0, + ...q, + }) + .then((it) => ({ + artists: it.searchResult3.artist || [], + albums: it.searchResult3.album || [], + songs: it.searchResult3.song || [], + })); + + private getAlbumList2 = (q: AlbumQuery) => + Promise.all([ + this.getArtists().then((it) => + inject(it, (total, artist) => total + artist.albumCount, 0) + ), + this.subsonic + .getJSON(this.credentials, "/rest/getAlbumList2", { + type: AlbumQueryTypeToSubsonicType[q.type], + ...(q.genre ? { genre: b64Decode(q.genre) } : {}), + size: 500, + offset: q._index, + }) + .then((response) => response.albumList2.album || []) + .then(this.toAlbumSummary), + ]).then(([total, albums]) => ({ + results: albums.slice(0, q._count), + total: albums.length == 500 ? total : (q._index || 0) + albums.length, + })); +} \ No newline at end of file diff --git a/tests/builders.ts b/tests/builders.ts index 83c1c7e..d3695de 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -17,7 +17,7 @@ import { } from "../src/music_service"; import { b64Encode } from "../src/b64"; -import { artistImageURN } from "../src/subsonic"; +import { artistImageURN } from "../src/subsonic/generic"; const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max)); const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`; diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 8dddb0c..94edbbf 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -2,19 +2,13 @@ import { Md5 } from "ts-md5/dist/md5"; import { v4 as uuid } from "uuid"; import { pipe } from "fp-ts/lib/function"; -import { option as O, taskEither as TE, task as T, either as E } from "fp-ts"; +import { taskEither as TE, task as T, either as E } from "fp-ts"; import { - isValidImage, Subsonic, t, DODGY_IMAGE_NAME, - asGenre, appendMimeTypeToClientFor, - asTrack, - artistImageURN, - images, - song, PingResponse, parseToken, asToken, @@ -30,34 +24,13 @@ import randomstring from "randomstring"; jest.mock("randomstring"); import { - Album, - Artist, - albumToAlbumSummary, - asArtistAlbumPairs, - Track, - AlbumSummary, - artistToArtistSummary, - AlbumQuery, - PlaylistSummary, - Playlist, - SimilarArtist, - Rating, AuthFailure, } from "../src/music_service"; import { - aGenre, - anAlbum, - anArtist, - aPlaylist, - aPlaylistSummary, - aSimilarArtist, aTrack, - POP, - ROCK, } from "./builders"; -import { b64Encode } from "../src/b64"; -import { BUrn } from "../src/burn"; import { asURLSearchParams } from "../src/utils"; +import { artistImageURN } from "../src/subsonic/generic"; describe("t", () => { it("should be an md5 of the password and the salt", () => { @@ -67,26 +40,6 @@ describe("t", () => { }); }); -describe("isValidImage", () => { - describe("when ends with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { - it("is dodgy", () => { - expect( - isValidImage("http://something/2a96cbd8b46e442fc41c2b86b821562f.png") - ).toEqual(false); - }); - }); - describe("when does not end with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { - it("is dodgy", () => { - expect(isValidImage("http://something/somethingelse.png")).toEqual(true); - expect( - isValidImage( - "http://something/2a96cbd8b46e442fc41c2b86b821562f.png?withsomequerystring=true" - ) - ).toEqual(true); - }); - }); -}); - describe("appendMimeTypeToUserAgentFor", () => { describe("when empty array", () => { it("should return bonob", () => { @@ -121,142 +74,12 @@ describe("appendMimeTypeToUserAgentFor", () => { }); }); -const ok = (data: string | object) => ({ +export const ok = (data: string | object) => ({ status: 200, data, }); -const asSimilarArtistJson = (similarArtist: SimilarArtist) => { - if (similarArtist.inLibrary) - return { - id: similarArtist.id, - name: similarArtist.name, - albumCount: 3, - }; - else - return { - id: -1, - name: similarArtist.name, - albumCount: 3, - }; -}; - -const getArtistInfoJson = ( - artist: Artist, - images: images = { - smallImageUrl: undefined, - mediumImageUrl: undefined, - largeImageUrl: undefined, - } -) => - subsonicOK({ - artistInfo2: { - ...images, - similarArtist: artist.similarArtists.map(asSimilarArtistJson), - }, - }); - -const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => pipe( - coverArt, - O.fromNullable, - O.map(it => it.resource.split(":")[1]), - O.getOrElseW(() => "") -) - -const asAlbumJson = ( - artist: { id: string | undefined; name: string | undefined }, - album: AlbumSummary, - tracks: Track[] = [] -) => ({ - id: album.id, - parent: artist.id, - isDir: "true", - title: album.name, - name: album.name, - album: album.name, - artist: artist.name, - genre: album.genre?.name, - coverArt: maybeIdFromCoverArtUrn(album.coverArt), - duration: "123", - playCount: "4", - year: album.year, - created: "2021-01-07T08:19:55.834207205Z", - artistId: artist.id, - songCount: "19", - isVideo: false, - song: tracks.map(asSongJson), -}); - -const asSongJson = (track: Track) => ({ - id: track.id, - parent: track.album.id, - title: track.name, - album: track.album.name, - artist: track.artist.name, - track: track.number, - genre: track.genre?.name, - isDir: "false", - coverArt: maybeIdFromCoverArtUrn(track.coverArt), - created: "2004-11-08T23:36:11", - duration: track.duration, - bitRate: 128, - size: "5624132", - suffix: "mp3", - contentType: track.mimeType, - isVideo: "false", - path: "ACDC/High voltage/ACDC - The Jack.mp3", - albumId: track.album.id, - artistId: track.artist.id, - type: "music", - starred: track.rating.love ? "sometime" : undefined, - userRating: track.rating.stars, - year: "", -}); - -const getAlbumListJson = (albums: [Artist, Album][]) => - subsonicOK({ - albumList2: { - album: albums.map(([artist, album]) => asAlbumJson(artist, album)), - }, - }); - -type ArtistExtras = { artistImageUrl: string | undefined } - -const asArtistJson = ( - artist: Artist, - extras: ArtistExtras = { artistImageUrl: undefined } -) => ({ - id: artist.id, - name: artist.name, - albumCount: artist.albums.length, - album: artist.albums.map((it) => asAlbumJson(artist, it)), - ...extras, -}); - -const getArtistJson = (artist: Artist, extras: ArtistExtras = { artistImageUrl: undefined }) => - subsonicOK({ - artist: asArtistJson(artist, extras), - }); - -const asGenreJson = (genre: { name: string; albumCount: number }) => ({ - songCount: 1475, - albumCount: genre.albumCount, - value: genre.name, -}); - -const getGenresJson = (genres: { name: string; albumCount: number }[]) => - subsonicOK({ - genres: { - genre: genres.map(asGenreJson), - }, - }); - -const getAlbumJson = (artist: Artist, album: Album, tracks: Track[]) => - subsonicOK({ album: asAlbumJson(artist, album, tracks) }); - -const getSongJson = (track: Track) => subsonicOK({ song: asSongJson(track) }); - -const subsonicOK = (body: any = {}) => ({ +export const subsonicOK = (body: any = {}) => ({ "subsonic-response": { status: "ok", version: "1.16.1", @@ -266,151 +89,8 @@ const subsonicOK = (body: any = {}) => ({ }, }); -const getSimilarSongsJson = (tracks: Track[]) => - subsonicOK({ similarSongs2: { song: tracks.map(asSongJson) } }); - -const getTopSongsJson = (tracks: Track[]) => - subsonicOK({ topSongs: { song: tracks.map(asSongJson) } }); - -export type ArtistWithAlbum = { - artist: Artist; - album: Album; -}; - -const asPlaylistJson = (playlist: PlaylistSummary) => ({ - id: playlist.id, - name: playlist.name, - songCount: 1, - duration: 190, - public: true, - owner: "bob", - created: "2021-05-06T02:07:24.308007023Z", - changed: "2021-05-06T02:08:06Z", -}); - -const getPlayListsJson = (playlists: PlaylistSummary[]) => - subsonicOK({ - playlists: { - playlist: playlists.map(asPlaylistJson), - }, - }); - -const createPlayListJson = (playlist: PlaylistSummary) => - subsonicOK({ - playlist: asPlaylistJson(playlist), - }); - -const getPlayListJson = (playlist: Playlist) => - subsonicOK({ - playlist: { - id: playlist.id, - name: playlist.name, - songCount: playlist.entries.length, - duration: 627, - public: true, - owner: "bob", - created: "2021-05-06T02:07:30.460465988Z", - changed: "2021-05-06T02:40:04Z", - entry: playlist.entries.map((it) => ({ - id: it.id, - parent: "...", - isDir: false, - title: it.name, - album: it.album.name, - artist: it.artist.name, - track: it.number, - year: it.album.year, - genre: it.album.genre?.name, - coverArt: maybeIdFromCoverArtUrn(it.coverArt), - size: 123, - contentType: it.mimeType, - suffix: "mp3", - duration: it.duration, - bitRate: 128, - path: "...", - discNumber: 1, - created: "2019-09-04T04:07:00.138169924Z", - albumId: it.album.id, - artistId: it.artist.id, - type: "music", - isVideo: false, - starred: it.rating.love ? "sometime" : undefined, - userRating: it.rating.stars, - })), - }, - }); - -const getSearchResult3Json = ({ - artists, - albums, - tracks, -}: Partial<{ - artists: Artist[]; - albums: ArtistWithAlbum[]; - tracks: Track[]; -}>) => - subsonicOK({ - searchResult3: { - artist: (artists || []).map((it) => asArtistJson({ ...it, albums: [] })), - album: (albums || []).map((it) => asAlbumJson(it.artist, it.album, [])), - song: (tracks || []).map((it) => asSongJson(it)), - }, - }); - -const asArtistsJson = (artists: Artist[]) => { - const as: Artist[] = []; - const bs: Artist[] = []; - const cs: Artist[] = []; - const rest: Artist[] = []; - artists.forEach((it) => { - const firstChar = it.name.toLowerCase()[0]; - switch (firstChar) { - case "a": - as.push(it); - break; - case "b": - bs.push(it); - break; - case "c": - cs.push(it); - break; - default: - rest.push(it); - break; - } - }); - - const asArtistSummary = (artist: Artist) => ({ - id: artist.id, - name: artist.name, - albumCount: artist.albums.length, - }); - - return subsonicOK({ - artists: { - index: [ - { - name: "A", - artist: as.map(asArtistSummary), - }, - { - name: "B", - artist: bs.map(asArtistSummary), - }, - { - name: "C", - artist: cs.map(asArtistSummary), - }, - { - name: "D-Z", - artist: rest.map(asArtistSummary), - }, - ], - }, - }); -}; -const error = (code: string, message: string) => ({ +export const error = (code: string, message: string) => ({ "subsonic-response": { status: "failed", version: "1.16.1", @@ -420,7 +100,7 @@ const error = (code: string, message: string) => ({ }, }); -const EMPTY = { +export const EMPTY = { "subsonic-response": { status: "ok", version: "1.16.1", @@ -429,7 +109,7 @@ const EMPTY = { }, }; -const FAILURE = { +export const FAILURE = { "subsonic-response": { status: "failed", version: "1.16.1", @@ -439,8 +119,6 @@ const FAILURE = { }, }; - - const pingJson = (pingResponse: Partial = {}) => ({ "subsonic-response": { status: "ok", @@ -525,117 +203,6 @@ describe("artistSummaryFromNDArtist", () => { }); }); -describe("artistURN", () => { - describe("when artist URL is", () => { - describe("a valid external URL", () => { - it("should return an external URN", () => { - expect( - artistImageURN({ artistId: "someArtistId", artistImageURL: "http://example.com/image.jpg" }) - ).toEqual({ system: "external", resource: "http://example.com/image.jpg" }); - }); - }); - - describe("an invalid external URL", () => { - describe("and artistId is valid", () => { - it("should return an external URN", () => { - expect( - artistImageURN({ - artistId: "someArtistId", - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` - }) - ).toEqual({ system: "subsonic", resource: "art:someArtistId" }); - }); - }); - - describe("and artistId is -1", () => { - it("should return an error icon urn", () => { - expect( - artistImageURN({ - artistId: "-1", - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` - }) - ).toBeUndefined(); - }); - }); - - describe("and artistId is undefined", () => { - it("should return an error icon urn", () => { - expect( - artistImageURN({ - artistId: undefined, - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` - }) - ).toBeUndefined(); - }); - }); - }); - - describe("undefined", () => { - describe("and artistId is valid", () => { - it("should return artist art by artist id URN", () => { - expect(artistImageURN({ artistId: "someArtistId", artistImageURL: undefined })).toEqual({system:"subsonic", resource:"art:someArtistId"}); - }); - }); - - describe("and artistId is -1", () => { - it("should return error icon", () => { - expect(artistImageURN({ artistId: "-1", artistImageURL: undefined })).toBeUndefined(); - }); - }); - - describe("and artistId is undefined", () => { - it("should return error icon", () => { - expect(artistImageURN({ artistId: undefined, artistImageURL: undefined })).toBeUndefined(); - }); - }); - }); - }); -}); - -describe("asTrack", () => { - describe("when the song has no artistId", () => { - const album = anAlbum(); - const track = aTrack({ artist: { id: undefined, name: "Not in library so no id", image: undefined }}); - - it("should provide no artistId", () => { - const result = asTrack(album, { ...asSongJson(track) }); - expect(result.artist.id).toBeUndefined(); - expect(result.artist.name).toEqual("Not in library so no id"); - expect(result.artist.image).toBeUndefined(); - }); - }); - - describe("when the song has no artist name", () => { - const album = anAlbum(); - - it("should provide a ? to sonos", () => { - const result = asTrack(album, { id: '1' } as any as song); - expect(result.artist.id).toBeUndefined(); - expect(result.artist.name).toEqual("?"); - expect(result.artist.image).toBeUndefined(); - }); - }); - - describe("invalid rating.stars values", () => { - const album = anAlbum(); - const track = aTrack(); - - describe("a value greater than 5", () => { - it("should be returned as 0", () => { - const result = asTrack(album, { ...asSongJson(track), userRating: 6 }); - expect(result.rating.stars).toEqual(0); - }); - }); - - describe("a value less than 0", () => { - it("should be returned as 0", () => { - const result = asTrack(album, { ...asSongJson(track), userRating: -1 }); - expect(result.rating.stars).toEqual(0); - }); - }); - }); -}); - describe("Subsonic", () => { const url = "http://127.0.0.22:4567"; const username = `user1-${uuid()}`; @@ -691,9 +258,6 @@ describe("Subsonic", () => { TE.fold(e => { throw e }, T.of) ) - const login = (credentials: Partial = {}) => tokenFor(credentials)() - .then((it) => subsonic.login(it.serviceToken)) - describe("generateToken", () => { describe("when the credentials are valid", () => { describe("when the backend is generic subsonic", () => { @@ -882,4017 +446,4 @@ describe("Subsonic", () => { }); }); - describe("getting genres", () => { - describe("when there are none", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(getGenresJson([])))); - }); - - it("should return empty array", async () => { - const result = await login({ username, password }) - .then((it) => it.genres()); - - expect(result).toEqual([]); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when there is only 1 that has an albumCount > 0", () => { - const genres = [ - { name: "genre1", albumCount: 1 }, - { name: "genreWithNoAlbums", albumCount: 0 }, - ]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getGenresJson(genres))) - ); - }); - - it("should return them alphabetically sorted", async () => { - const result = await login({ username, password }) - .then((it) => it.genres()); - - expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when there are many that have an albumCount > 0", () => { - const genres = [ - { name: "g1", albumCount: 1 }, - { name: "g2", albumCount: 1 }, - { name: "g3", albumCount: 1 }, - { name: "g4", albumCount: 1 }, - { name: "someGenreWithNoAlbums", albumCount: 0 }, - ]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getGenresJson(genres))) - ); - }); - - it("should return them alphabetically sorted", async () => { - const result = await login({ username, password }) - .then((it) => it.genres()); - - expect(result).toEqual([ - { id: b64Encode("g1"), name: "g1" }, - { id: b64Encode("g2"), name: "g2" }, - { id: b64Encode("g3"), name: "g3" }, - { id: b64Encode("g4"), name: "g4" }, - ]); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - }); - - describe("getting an artist", () => { - describe("when the artist exists", () => { - describe("and has many similar artists", () => { - const album1: Album = anAlbum({ genre: asGenre("Pop") }); - - const album2: Album = anAlbum({ genre: asGenre("Pop") }); - - const artist: Artist = anArtist({ - albums: [album1, album2], - similarArtists: [ - aSimilarArtist({ - id: "similar1.id", - name: "similar1", - inLibrary: true, - }), - aSimilarArtist({ id: "-1", name: "similar2", inLibrary: false }), - aSimilarArtist({ - id: "similar3.id", - name: "similar3", - inLibrary: true, - }), - aSimilarArtist({ id: "-1", name: "similar4", inLibrary: false }), - ], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return the similar artists", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: `${artist.id}`, - name: artist.name, - image: { system:"subsonic", resource:`art:${artist.id}` }, - albums: artist.albums, - similarArtists: artist.similarArtists, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has one similar artist", () => { - const album1: Album = anAlbum({ genre: asGenre("G1") }); - - const album2: Album = anAlbum({ genre: asGenre("G2") }); - - const artist: Artist = anArtist({ - albums: [album1, album2], - similarArtists: [ - aSimilarArtist({ - id: "similar1.id", - name: "similar1", - inLibrary: true, - }), - ], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return the similar artists", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system:"subsonic", resource:`art:${artist.id}` }, - albums: artist.albums, - similarArtists: artist.similarArtists, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has no similar artists", () => { - const album1: Album = anAlbum({ genre: asGenre("Jock") }); - - const album2: Album = anAlbum({ genre: asGenre("Mock") }); - - const artist: Artist = anArtist({ - albums: [album1, album2], - similarArtists: [], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return the similar artists", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system:"subsonic", resource: `art:${artist.id}` }, - albums: artist.albums, - similarArtists: artist.similarArtists, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has dodgy looking artist image uris", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl}))) - ); - }); - - it("should return remove the dodgy looking image uris and return urn for artist:id", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { - system: "subsonic", - resource: `art:${artist.id}`, - }, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has a good external image uri from getArtist route", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: 'http://example.com:1234/good/looking/image.png' }))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl }))) - ); - }); - - it("should use the external url", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system: "external", resource: 'http://example.com:1234/good/looking/image.png' }, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has a good large external image uri from getArtistInfo route", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: 'http://example.com:1234/good/large/image.png' }))) - ); - }); - - it("should use the external url", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system: "external", resource: 'http://example.com:1234/good/large/image.png' }, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - - describe("and has a good medium external image uri from getArtistInfo route", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: 'http://example.com:1234/good/medium/image.png', largeImageUrl: dodgyImageUrl }))) - ); - }); - - it("should use the external url", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system:"external", resource: 'http://example.com:1234/good/medium/image.png' }, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has multiple albums", () => { - const album1: Album = anAlbum({ genre: asGenre("Pop") }); - - const album2: Album = anAlbum({ genre: asGenre("Flop") }); - - const artist: Artist = anArtist({ - albums: [album1, album2], - similarArtists: [], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return it", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: artist.image, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has only 1 album", () => { - const album: Album = anAlbum({ genre: asGenre("Pop") }); - - const artist: Artist = anArtist({ - albums: [album], - similarArtists: [], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return it", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: artist.image, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has no albums", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return it", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: artist.image, - albums: [], - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - }); - }); - - describe("getting artists", () => { - 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( - 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, - 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: [ - artistSummaryFromNDArtist(ndArtist1), - artistSummaryFromNDArtist(ndArtist2), - artistSummaryFromNDArtist(ndArtist3), - artistSummaryFromNDArtist(ndArtist4), - ], - total: 4, - }); - - 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 start index 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: undefined })); - - 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" - }), - 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}`, - }, - }); - }); - }); - - }); - }); - - describe("getting albums", () => { - describe("filtering", () => { - const album1 = anAlbum({ id: "album1", genre: asGenre("Pop") }); - const album2 = anAlbum({ id: "album2", genre: asGenre("Rock") }); - const album3 = anAlbum({ id: "album3", genre: asGenre("Pop") }); - const album4 = anAlbum({ id: "album4", genre: asGenre("Pop") }); - const album5 = anAlbum({ id: "album5", genre: asGenre("Pop") }); - - const artist = anArtist({ - albums: [album1, album2, album3, album4, album5], - }); - - describe("by genre", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist, album1], - // album2 is not Pop - [artist, album3], - ]) - ) - ) - ); - }); - - it("should map the 64 encoded genre back into the subsonic genre", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - genre: b64Encode("Pop"), - type: "byGenre", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album1, album3].map(albumToAlbumSummary), - total: 2, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "byGenre", - genre: "Pop", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("by newest", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist, album3], - [artist, album2], - [artist, album1], - ]) - ) - ) - ); - }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "recentlyAdded", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album3, album2, album1].map(albumToAlbumSummary), - total: 3, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "newest", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("by recently played", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist, album3], - [artist, album2], - // album1 never played - ]) - ) - ) - ); - }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "recentlyPlayed", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album3, album2].map(albumToAlbumSummary), - total: 2, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "recent", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("by frequently played", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce( - () => - // album1 never played - Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) - // album3 never played - ); - }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "mostPlayed" }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album2].map(albumToAlbumSummary), - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "frequent", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("by starred", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce( - () => - // album1 never played - Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) - // album3 never played - ); - }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "starred" }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album2].map(albumToAlbumSummary), - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "highest", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - }); - - describe("when the artist has only 1 album", () => { - const artist = anArtist({ - name: "one hit wonder", - albums: [anAlbum({ genre: asGenre("Pop") })], - }); - const artists = [artist]; - const albums = artists.flatMap((artist) => artist.albums); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) - ); - }); - - it("should return the album", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: albums, - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("when the only artist has no albums", () => { - const artist = anArtist({ - name: "no hit wonder", - albums: [], - }); - const artists = [artist]; - const albums = artists.flatMap((artist) => artist.albums); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) - ); - }); - - it("should return the album", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: albums, - total: 0, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("when there are 6 albums in total", () => { - const genre1 = asGenre("genre1"); - const genre2 = asGenre("genre2"); - const genre3 = asGenre("genre3"); - - const artist1 = anArtist({ - name: "abba", - albums: [ - anAlbum({ name: "album1", genre: genre1 }), - anAlbum({ name: "album2", genre: genre2 }), - anAlbum({ name: "album3", genre: genre3 }), - ], - }); - const artist2 = anArtist({ - name: "babba", - albums: [ - anAlbum({ name: "album4", genre: genre1 }), - anAlbum({ name: "album5", genre: genre2 }), - anAlbum({ name: "album6", genre: genre3 }), - ], - }); - const artists = [artist1, artist2]; - const albums = artists.flatMap((artist) => artist.albums); - - describe("querying for all of them", () => { - it("should return all of them with corrent paging information", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) - ); - - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: albums, - total: 6, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("querying for a page of them", () => { - it("should return the page with the corrent paging information", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, artist1.albums[2]!], - [artist2, artist2.albums[0]!], - // due to pre-fetch will get next 2 albums also - [artist2, artist2.albums[1]!], - [artist2, artist2.albums[2]!], - ]) - ) - ) - ); - - const q: AlbumQuery = { - _index: 2, - _count: 2, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [artist1.albums[2], artist2.albums[0]], - total: 6, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 2, - }), - headers, - }); - }); - }); - }); - - describe("when the number of albums reported by getArtists does not match that of getAlbums", () => { - const genre = asGenre("lofi"); - - const album1 = anAlbum({ name: "album1", genre }); - const album2 = anAlbum({ name: "album2", genre }); - const album3 = anAlbum({ name: "album3", genre }); - const album4 = anAlbum({ name: "album4", genre }); - const album5 = anAlbum({ name: "album5", genre }); - - // the artists have 5 albums in the getArtists endpoint - const artist1 = anArtist({ - albums: [album1, album2, album3, album4], - }); - const artist2 = anArtist({ - albums: [album5], - }); - const artists = [artist1, artist2]; - - describe("when the number of albums returned from getAlbums is less the number of albums in the getArtists endpoint", () => { - describe("when the query comes back on 1 page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album1], - [artist1, album2], - [artist1, album3], - // album4 is missing from the albums end point for some reason - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should return the page of albums, updating the total to be accurate", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album1, album2, album3, album5], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - - describe("when the query is for the first page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album1], - [artist1, album2], - // album3 & album5 is returned due to the prefetch - [artist1, album3], - // album4 is missing from the albums end point for some reason - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should filter out the pre-fetched albums", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 2, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album1, album2], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - - describe("when the query is for the last page only", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - // album1 is on the first page - // album2 is on the first page - [artist1, album3], - // album4 is missing from the albums end point for some reason - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should return the last page of albums, updating the total to be accurate", async () => { - const q: AlbumQuery = { - _index: 2, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album3, album5], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - }); - - describe("when the number of albums returned from getAlbums is more than the number of albums in the getArtists endpoint", () => { - describe("when the query comes back on 1 page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - asArtistsJson([ - // artist1 has lost 2 albums on the getArtists end point - { ...artist1, albums: [album1, album2] }, - artist2, - ]) - ) - ) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album1], - [artist1, album2], - [artist1, album3], - [artist1, album4], - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should return the page of albums, updating the total to be accurate", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album1, album2, album3, album4, album5], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - - describe("when the query is for the first page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - asArtistsJson([ - // artist1 has lost 2 albums on the getArtists end point - { ...artist1, albums: [album1, album2] }, - artist2, - ]) - ) - ) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album1], - [artist1, album2], - [artist1, album3], - [artist1, album4], - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should filter out the pre-fetched albums", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 2, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album1, album2], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - - describe("when the query is for the last page only", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - asArtistsJson([ - // artist1 has lost 2 albums on the getArtists end point - { ...artist1, albums: [album1, album2] }, - artist2, - ]) - ) - ) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album3], - [artist1, album4], - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should return the last page of albums, updating the total to be accurate", async () => { - const q: AlbumQuery = { - _index: 2, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album3, album4, album5], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - }); - }); - }); - - describe("getting an album", () => { - describe("when it exists", () => { - const genre = asGenre("Pop"); - - const album = anAlbum({ genre }); - - const artist = anArtist({ albums: [album] }); - - const tracks = [ - aTrack({ artist, album, genre }), - aTrack({ artist, album, genre }), - aTrack({ artist, album, genre }), - aTrack({ artist, album, genre }), - ]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should return the album", async () => { - const result = await login({ username, password }) - .then((it) => it.album(album.id)); - - expect(result).toEqual(album); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - }); - - describe("getting tracks", () => { - describe("for an album", () => { - describe("when the album has multiple tracks, some of which are rated", () => { - const hipHop = asGenre("Hip-Hop"); - const tripHop = asGenre("Trip-Hop"); - - const album = anAlbum({ id: "album1", name: "Burnin", genre: hipHop }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - const track1 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: hipHop, - rating: { - love: true, - stars: 3, - }, - }); - const track2 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: hipHop, - rating: { - love: false, - stars: 0, - }, - }); - const track3 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: tripHop, - rating: { - love: true, - stars: 5, - }, - }); - const track4 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: tripHop, - rating: { - love: false, - stars: 1, - }, - }); - - const tracks = [track1, track2, track3, track4]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should return the album", async () => { - const result = await login({ username, password }) - .then((it) => it.tracks(album.id)); - - expect(result).toEqual([track1, track2, track3, track4]); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - - describe("when the album has only 1 track", () => { - const flipFlop = asGenre("Flip-Flop"); - - const album = anAlbum({ - id: "album1", - name: "Burnin", - genre: flipFlop, - }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: flipFlop, - }); - - const tracks = [track]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should return the album", async () => { - const result = await login({ username, password }) - .then((it) => it.tracks(album.id)); - - expect(result).toEqual([track]); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - - describe("when the album has only no tracks", () => { - const album = anAlbum({ id: "album1", name: "Burnin" }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - const tracks: Track[] = []; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should empty array", async () => { - const result = await login({ username, password }) - .then((it) => it.tracks(album.id)); - - expect(result).toEqual([]); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - }); - - describe("a single track", () => { - const pop = asGenre("Pop"); - - const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - describe("that is starred", () => { - it("should return the track", async () => { - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: pop, - rating: { - love: true, - stars: 4, - }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.track(track.id)); - - expect(result).toEqual({ - ...track, - rating: { love: true, stars: 4 }, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: track.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - - describe("that is not starred", () => { - it("should return the track", async () => { - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: pop, - rating: { - love: false, - stars: 0, - }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.track(track.id)); - - expect(result).toEqual({ - ...track, - rating: { love: false, stars: 0 }, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: track.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - }); - }); - - describe("streaming a track", () => { - const trackId = uuid(); - const genre = aGenre("foo"); - - const album = anAlbum({ genre }); - const artist = anArtist({ - albums: [album] - }); - const track = aTrack({ - id: trackId, - album: albumToAlbumSummary(album), - artist: artistToArtistSummary(artist), - genre, - }); - - describe("content-range, accept-ranges or content-length", () => { - beforeEach(() => { - streamClientApplication.mockReturnValue("bonob"); - }); - - describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => { - it("should return undefined values", async () => { - const stream = { - pipe: jest.fn(), - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.stream({ trackId, range: undefined })); - - expect(result.headers).toEqual({ - "content-type": "audio/mpeg", - "content-length": undefined, - "content-range": undefined, - "accept-ranges": undefined, - }); - }); - }); - - describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => { - it("should return undefined values", async () => { - const stream = { - pipe: jest.fn(), - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - "content-length": undefined, - "content-range": undefined, - "accept-ranges": undefined, - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.stream({ trackId, range: undefined })); - - expect(result.headers).toEqual({ - "content-type": "audio/mpeg", - "content-length": undefined, - "content-range": undefined, - "accept-ranges": undefined, - }); - }); - }); - - describe("with no range specified", () => { - describe("navidrome returns a 200", () => { - it("should return the content", async () => { - const stream = { - pipe: jest.fn(), - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - "content-length": "1667", - "content-range": "-200", - "accept-ranges": "bytes", - "some-other-header": "some-value", - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.stream({ trackId, range: undefined })); - - expect(result.headers).toEqual({ - "content-type": "audio/mpeg", - "content-length": "1667", - "content-range": "-200", - "accept-ranges": "bytes", - }); - expect(result.stream).toEqual(stream); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: asURLSearchParams({ - ...authParams, - id: trackId, - }), - headers: { - "User-Agent": "bonob", - }, - responseType: "stream", - }); - }); - }); - - describe("navidrome returns something other than a 200", () => { - it("should fail", async () => { - const trackId = "track123"; - - const streamResponse = { - status: 400, - headers: { - 'content-type': 'text/html', - 'content-length': '33' - } - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const musicLibrary = await login({ username, password }); - - return expect( - musicLibrary.stream({ trackId, range: undefined }) - ).rejects.toEqual(`Subsonic failed with a 400 status`); - }); - }); - - describe("io exception occurs", () => { - it("should fail", async () => { - const trackId = "track123"; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.reject("IO error occured")); - - const musicLibrary = await login({ username, password }); - - return expect( - musicLibrary.stream({ trackId, range: undefined }) - ).rejects.toEqual(`Subsonic failed with: IO error occured`); - }); - }); - }); - - describe("with range specified", () => { - it("should send the range to navidrome", async () => { - const stream = { - pipe: jest.fn(), - }; - - const range = "1000-2000"; - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/flac", - "content-length": "66", - "content-range": "100-200", - "accept-ranges": "none", - "some-other-header": "some-value", - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.stream({ trackId, range })); - - expect(result.headers).toEqual({ - "content-type": "audio/flac", - "content-length": "66", - "content-range": "100-200", - "accept-ranges": "none", - }); - expect(result.stream).toEqual(stream); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: asURLSearchParams({ - ...authParams, - id: trackId, - }), - headers: { - "User-Agent": "bonob", - Range: range, - }, - responseType: "stream", - }); - }); - }); - }); - - describe("when navidrome has a custom StreamClientApplication registered", () => { - describe("when no range specified", () => { - it("should user the custom StreamUserAgent when calling navidrome", async () => { - const clientApplication = `bonob-${uuid()}`; - streamClientApplication.mockReturnValue(clientApplication); - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - }, - data: Buffer.from("the track", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, [track]))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - await login({ username, password }) - .then((it) => it.stream({ trackId, range: undefined })); - - expect(streamClientApplication).toHaveBeenCalledWith(track); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: asURLSearchParams({ - ...authParams, - id: trackId, - c: clientApplication, - }), - headers: { - "User-Agent": "bonob", - }, - responseType: "stream", - }); - }); - }); - - describe("when range specified", () => { - it("should user the custom StreamUserAgent when calling navidrome", async () => { - const range = "1000-2000"; - const clientApplication = `bonob-${uuid()}`; - streamClientApplication.mockReturnValue(clientApplication); - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - }, - data: Buffer.from("the track", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, [track]))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - await login({ username, password }) - .then((it) => it.stream({ trackId, range })); - - expect(streamClientApplication).toHaveBeenCalledWith(track); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: asURLSearchParams({ - ...authParams, - id: trackId, - c: clientApplication, - }), - headers: { - "User-Agent": "bonob", - Range: range, - }, - responseType: "stream", - }); - }); - }); - }); - }); - - describe("fetching cover art", () => { - describe("fetching album art", () => { - describe("when no size is specified", () => { - it("should fetch the image", async () => { - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - const coverArtId = "someCoverArt"; - const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.coverArt(coverArtURN)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - }), - headers, - responseType: "arraybuffer", - }); - }); - }); - - describe("when size is specified", () => { - it("should fetch the image", async () => { - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - const coverArtId = uuid(); - const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` } - const size = 1879; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.coverArt(coverArtURN, size)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - size, - }), - headers, - responseType: "arraybuffer", - }); - }); - }); - - describe("when an unexpected error occurs", () => { - it("should return undefined", async () => { - const size = 1879; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.reject("BOOOM")); - - const result = await login({ username, password }) - .then((it) => it.coverArt({ system: "external", resource: "http://localhost:404" }, size)); - - expect(result).toBeUndefined(); - }); - }); - }); - - describe("fetching cover art", () => { - describe("when urn.resource is not subsonic", () => { - it("should be undefined", async () => { - const covertArtURN = { system: "notSubsonic", resource: `art:${uuid()}` }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))); - - const result = await login({ username, password }) - .then((it) => it.coverArt(covertArtURN, 190)); - - expect(result).toBeUndefined(); - }); - }); - - describe("when no size is specified", () => { - it("should fetch the image", async () => { - const coverArtId = uuid() - const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.coverArt(covertArtURN)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - }), - headers, - responseType: "arraybuffer", - } - ); - }); - - describe("and an error occurs fetching the uri", () => { - it("should return undefined", async () => { - const coverArtId = uuid() - const covertArtURN = { system:"subsonic", resource: `art:${coverArtId}` }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.reject("BOOOM")); - - const result = await login({ username, password }) - .then((it) => it.coverArt(covertArtURN)); - - expect(result).toBeUndefined(); - }); - }); - }); - - describe("when size is specified", () => { - const size = 189; - - it("should fetch the image", async () => { - const coverArtId = uuid() - const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.coverArt(covertArtURN, size)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - size - }), - headers, - responseType: "arraybuffer", - } - ); - }); - - describe("and an error occurs fetching the uri", () => { - it("should return undefined", async () => { - const coverArtId = uuid() - const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.reject("BOOOM")); - - const result = await login({ username, password }) - .then((it) => it.coverArt(covertArtURN, size)); - - expect(result).toBeUndefined(); - }); - }); - }); - }); - }); - - describe("rate", () => { - const trackId = uuid(); - - const rate = (trackId: string, rating: Rating) => - login({ username, password }) - .then((it) => it.rate(trackId, rating)); - - const artist = anArtist(); - const album = anAlbum({ id: "album1", name: "Burnin", genre: POP }); - - describe("rating a track", () => { - describe("loving a track that isnt already loved", () => { - it("should mark the track as loved", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: false, stars: 0 }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await rate(trackId, { love: true, stars: 0 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/star`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - }), - headers, - }); - }); - }); - - describe("unloving a track that is loved", () => { - it("should mark the track as loved", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: true, stars: 0 }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await rate(trackId, { love: false, stars: 0 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/unstar`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - }), - headers, - }); - }); - }); - - describe("loving a track that is already loved", () => { - it("shouldn't do anything", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: true, stars: 0 }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await rate(trackId, { love: true, stars: 0 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledTimes(3); - }); - }); - - describe("rating a track with a different rating", () => { - it("should add the new rating", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: false, stars: 0 }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await rate(trackId, { love: false, stars: 3 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/setRating`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - rating: 3, - }), - headers, - }); - }); - }); - - describe("rating a track with the same rating it already has", () => { - it("shouldn't do anything", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: true, stars: 3 }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await rate(trackId, { love: true, stars: 3 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledTimes(3); - }); - }); - - describe("loving and rating a track", () => { - it("should return true", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: true, stars: 3 }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await rate(trackId, { love: false, stars: 5 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/unstar`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - }), - headers, - }); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/setRating`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - rating: 5, - }), - headers, - }); - }); - }); - - describe("invalid star values", () => { - describe("stars of -1", () => { - it("should return false", async () => { - mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))); - - const result = await rate(trackId, { love: true, stars: -1 }); - expect(result).toEqual(false); - }); - }); - - describe("stars of 6", () => { - it("should return false", async () => { - mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))); - - const result = await rate(trackId, { love: true, stars: -1 }); - expect(result).toEqual(false); - }); - }); - }); - - describe("when fails", () => { - it("should return false", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(FAILURE))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await rate(trackId, { love: true, stars: 0 }); - - expect(result).toEqual(false); - }); - }); - }); - }); - - describe("scrobble", () => { - describe("when succeeds", () => { - it("should return true", async () => { - const id = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await login({ username, password }) - .then((it) => it.scrobble(id)); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - submission: true, - }), - headers, - }); - }); - }); - - describe("when fails", () => { - it("should return false", async () => { - const id = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve({ - status: 500, - data: {}, - }) - ); - - const result = await login({ username, password }) - .then((it) => it.scrobble(id)); - - expect(result).toEqual(false); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - submission: true, - }), - headers, - }); - }); - }); - }); - - describe("nowPlaying", () => { - describe("when succeeds", () => { - it("should return true", async () => { - const id = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await login({ username, password }) - .then((it) => it.nowPlaying(id)); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - submission: false, - }), - headers, - }); - }); - }); - - describe("when fails", () => { - it("should return false", async () => { - const id = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve({ - status: 500, - data: {}, - }) - ); - - const result = await login({ username, password }) - .then((it) => it.nowPlaying(id)); - - expect(result).toEqual(false); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - submission: false, - }), - headers, - }); - }); - }); - }); - - describe("searchArtists", () => { - describe("when there is 1 search results", () => { - it("should return true", async () => { - const artist1 = anArtist({ name: "foo woo" }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ artists: [artist1] }))) - ); - - const result = await login({ username, password }) - .then((it) => it.searchArtists("foo")); - - expect(result).toEqual([artistToArtistSummary(artist1)]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 20, - albumCount: 0, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - - describe("when there are many search results", () => { - it("should return true", async () => { - const artist1 = anArtist({ name: "foo woo" }); - const artist2 = anArtist({ name: "foo choo" }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok(getSearchResult3Json({ artists: [artist1, artist2] })) - ) - ); - - const result = await login({ username, password }) - .then((it) => it.searchArtists("foo")); - - expect(result).toEqual([ - artistToArtistSummary(artist1), - artistToArtistSummary(artist2), - ]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 20, - albumCount: 0, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - - describe("when there are no search results", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) - ); - - const result = await login({ username, password }) - .then((it) => it.searchArtists("foo")); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 20, - albumCount: 0, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - }); - - describe("searchAlbums", () => { - describe("when there is 1 search results", () => { - it("should return true", async () => { - const album = anAlbum({ - name: "foo woo", - genre: { id: b64Encode("pop"), name: "pop" }, - }); - const artist = anArtist({ name: "#1", albums: [album] }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok(getSearchResult3Json({ albums: [{ artist, album }] })) - ) - ); - - const result = await login({ username, password }) - .then((it) => it.searchAlbums("foo")); - - expect(result).toEqual([albumToAlbumSummary(album)]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 20, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - - describe("when there are many search results", () => { - it("should return true", async () => { - const album1 = anAlbum({ - name: "album1", - genre: { id: b64Encode("pop"), name: "pop" }, - }); - const artist1 = anArtist({ name: "artist1", albums: [album1] }); - - const album2 = anAlbum({ - name: "album2", - genre: { id: b64Encode("pop"), name: "pop" }, - }); - const artist2 = anArtist({ name: "artist2", albums: [album2] }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getSearchResult3Json({ - albums: [ - { artist: artist1, album: album1 }, - { artist: artist2, album: album2 }, - ], - }) - ) - ) - ); - - const result = await login({ username, password }) - .then((it) => it.searchAlbums("moo")); - - expect(result).toEqual([ - albumToAlbumSummary(album1), - albumToAlbumSummary(album2), - ]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 20, - songCount: 0, - query: "moo", - }), - headers, - }); - }); - }); - - describe("when there are no search results", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ albums: [] }))) - ); - - const result = await login({ username, password }) - .then((it) => it.searchAlbums("foo")); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 20, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - }); - - describe("searchSongs", () => { - describe("when there is 1 search results", () => { - it("should return true", async () => { - const pop = asGenre("Pop"); - - const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: pop, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ tracks: [track] }))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track)))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.searchTracks("foo")); - - expect(result).toEqual([track]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 0, - songCount: 20, - query: "foo", - }), - headers, - }); - }); - }); - - describe("when there are many search results", () => { - it("should return true", async () => { - const pop = asGenre("Pop"); - - const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - const artist1 = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album1], - }); - const track1 = aTrack({ - id: "track1", - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - genre: pop, - }); - - const album2 = anAlbum({ id: "album2", name: "Bobbin", genre: pop }); - const artist2 = anArtist({ - id: "artist2", - name: "Jane Marley", - albums: [album2], - }); - const track2 = aTrack({ - id: "track2", - artist: artistToArtistSummary(artist2), - album: albumToAlbumSummary(album2), - genre: pop, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getSearchResult3Json({ - tracks: [track1, track2], - }) - ) - ) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track1))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track2))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist2, album2, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.searchTracks("moo")); - - expect(result).toEqual([track1, track2]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 0, - songCount: 20, - query: "moo", - }), - headers, - }); - }); - }); - - describe("when there are no search results", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ tracks: [] }))) - ); - - const result = await login({ username, password }) - .then((it) => it.searchTracks("foo")); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 0, - songCount: 20, - query: "foo", - }), - headers, - }); - }); - }); - }); - - describe("playlists", () => { - describe("getting playlists", () => { - describe("when there is 1 playlist results", () => { - it("should return it", async () => { - const playlist = aPlaylistSummary(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson([playlist]))) - ); - - const result = await login({ username, password }) - .then((it) => it.playlists()); - - expect(result).toEqual([playlist]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when there are many playlists", () => { - it("should return them", async () => { - const playlist1 = aPlaylistSummary(); - const playlist2 = aPlaylistSummary(); - const playlist3 = aPlaylistSummary(); - const playlists = [playlist1, playlist2, playlist3]; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson(playlists))) - ); - - const result = await login({ username, password }) - .then((it) => it.playlists()); - - expect(result).toEqual(playlists); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when there are no playlists", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson([]))) - ); - - const result = await login({ username, password }) - .then((it) => it.playlists()); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - }); - - describe("getting a single playlist", () => { - describe("when there is no playlist with the id", () => { - it("should raise error", async () => { - const id = "id404"; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(error("70", "data not found"))) - ); - - return expect( - login({ username, password }) - .then((it) => it.playlist(id)) - ).rejects.toEqual("Subsonic error:data not found"); - }); - }); - - describe("when there is a playlist with the id", () => { - describe("and it has tracks", () => { - it("should return the playlist with entries", async () => { - const id = uuid(); - const name = "Great Playlist"; - const artist1 = anArtist(); - const album1 = anAlbum({ - artistId: artist1.id, - artistName: artist1.name, - genre: POP, - }); - const track1 = aTrack({ - genre: POP, - number: 66, - coverArt: album1.coverArt, - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - }); - - const artist2 = anArtist(); - const album2 = anAlbum({ - artistId: artist2.id, - artistName: artist2.name, - genre: ROCK, - }); - const track2 = aTrack({ - genre: ROCK, - number: 77, - coverArt: album2.coverArt, - artist: artistToArtistSummary(artist2), - album: albumToAlbumSummary(album2), - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getPlayListJson({ - id, - name, - entries: [track1, track2], - }) - ) - ) - ); - - const result = await login({ username, password }) - .then((it) => it.playlist(id)); - - expect(result).toEqual({ - id, - name, - entries: [ - { ...track1, number: 1 }, - { ...track2, number: 2 }, - ], - }); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - }), - headers, - }); - }); - }); - - describe("and it has no tracks", () => { - it("should return the playlist with empty entries", async () => { - const playlist = aPlaylist({ - entries: [], - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListJson(playlist))) - ); - - const result = await login({ username, password }) - .then((it) => it.playlist(playlist.id)); - - expect(result).toEqual(playlist); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: playlist.id, - }), - headers, - }); - }); - }); - }); - }); - - describe("creating a playlist", () => { - it("should create a playlist with the given name", async () => { - const name = "ThePlaylist"; - const id = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(createPlayListJson({ id, name }))) - ); - - const result = await login({ username, password }) - .then((it) => it.createPlaylist(name)); - - expect(result).toEqual({ id, name }); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/createPlaylist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - f: "json", - name, - }), - headers, - }); - }); - }); - - describe("deleting a playlist", () => { - it("should delete the playlist by id", async () => { - const id = "id-to-delete"; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await login({ username, password }) - .then((it) => it.deletePlaylist(id)); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/deletePlaylist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - }), - headers, - }); - }); - }); - - describe("editing playlists", () => { - describe("adding a track to a playlist", () => { - it("should add it", async () => { - const playlistId = uuid(); - const trackId = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await login({ username, password }) - .then((it) => it.addToPlaylist(playlistId, trackId)); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - playlistId, - songIdToAdd: trackId, - }), - headers, - }); - }); - }); - - describe("removing a track from a playlist", () => { - it("should remove it", async () => { - const playlistId = uuid(); - const indicies = [6, 100, 33]; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await login({ username, password }) - .then((it) => it.removeFromPlaylist(playlistId, indicies)); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - playlistId, - songIndexToRemove: indicies, - }), - headers, - }); - }); - }); - }); - }); - - describe("similarSongs", () => { - describe("when there is one similar songs", () => { - it("should return it", async () => { - const id = "idWithTracks"; - const pop = asGenre("Pop"); - - const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - const artist1 = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album1], - }); - - const track1 = aTrack({ - id: "track1", - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - genre: pop, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSimilarSongsJson([track1]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.similarSongs(id)); - - expect(result).toEqual([track1]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { - params: asURLSearchParams({ - ...authParams, - f: "json", - id, - count: 50, - }), - headers, - }); - }); - }); - - describe("when there are similar songs", () => { - it("should return them", async () => { - const id = "idWithTracks"; - const pop = asGenre("Pop"); - - const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - const artist1 = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album1], - }); - - const album2 = anAlbum({ id: "album2", name: "Walking", genre: pop }); - const artist2 = anArtist({ - id: "artist2", - name: "Bob Jane", - albums: [album2], - }); - - const track1 = aTrack({ - id: "track1", - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - genre: pop, - }); - const track2 = aTrack({ - id: "track2", - artist: artistToArtistSummary(artist2), - album: albumToAlbumSummary(album2), - genre: pop, - }); - const track3 = aTrack({ - id: "track3", - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - genre: pop, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSimilarSongsJson([track1, track2, track3]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist2, album2, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.similarSongs(id)); - - expect(result).toEqual([track1, track2, track3]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { - params: asURLSearchParams({ - ...authParams, - f: "json", - id, - count: 50, - }), - headers, - }); - }); - }); - - describe("when there are no similar songs", () => { - it("should return []", async () => { - const id = "idWithNoTracks"; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSimilarSongsJson([]))) - ); - - const result = await login({ username, password }) - .then((it) => it.similarSongs(id)); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { - params: asURLSearchParams({ - ...authParams, - f: "json", - id, - count: 50, - }), - headers, - }); - }); - }); - - describe("when the id doesnt exist", () => { - it("should fail", async () => { - const id = "idThatHasAnError"; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(error("70", "data not found"))) - ); - - return expect( - login({ username, password }) - .then((it) => it.similarSongs(id)) - ).rejects.toEqual("Subsonic error:data not found"); - }); - }); - }); - - describe("topSongs", () => { - describe("when there is one top song", () => { - it("should return it", async () => { - const artistId = "bobMarleyId"; - const artistName = "Bob Marley"; - const pop = asGenre("Pop"); - - const album1 = anAlbum({ name: "Burnin", genre: pop }); - const artist = anArtist({ - id: artistId, - name: artistName, - albums: [album1], - }); - - const track1 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album1), - genre: pop, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getTopSongsJson([track1]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album1, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.topSongs(artistId)); - - expect(result).toEqual([track1]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, - }); - }); - }); - - describe("when there are many top songs", () => { - it("should return them", async () => { - const artistId = "bobMarleyId"; - const artistName = "Bob Marley"; - - const album1 = anAlbum({ name: "Burnin", genre: POP }); - const album2 = anAlbum({ name: "Churning", genre: POP }); - - const artist = anArtist({ - id: artistId, - name: artistName, - albums: [album1, album2], - }); - - const track1 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album1), - genre: POP, - }); - - const track2 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album2), - genre: POP, - }); - - const track3 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album1), - genre: POP, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getTopSongsJson([track1, track2, track3]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album1, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album2, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album1, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.topSongs(artistId)); - - expect(result).toEqual([track1, track2, track3]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, - }); - }); - }); - - describe("when there are no similar songs", () => { - it("should return []", async () => { - const artistId = "bobMarleyId"; - const artistName = "Bob Marley"; - const pop = asGenre("Pop"); - - const album1 = anAlbum({ name: "Burnin", genre: pop }); - const artist = anArtist({ - id: artistId, - name: artistName, - albums: [album1], - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getTopSongsJson([]))) - ); - - - const result = await login({ username, password }) - .then((it) => it.topSongs(artistId)); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, - }); - }); - }); - }); }); diff --git a/tests/subsonic/generic.test.ts b/tests/subsonic/generic.test.ts new file mode 100644 index 0000000..500b509 --- /dev/null +++ b/tests/subsonic/generic.test.ts @@ -0,0 +1,4390 @@ +import { pipe } from "fp-ts/lib/function"; +import { option as O } from "fp-ts"; +import { v4 as uuid } from "uuid"; + +import axios from "axios"; +jest.mock("axios"); + + +import randomstring from "randomstring"; +jest.mock("randomstring"); + +import { aGenre, anAlbum, anArtist, aPlaylist, aPlaylistSummary, aSimilarArtist, aTrack, POP, ROCK } from "../builders"; +import { BUrn } from "../../src/burn"; +import { Album, AlbumQuery, AlbumSummary, albumToAlbumSummary, Artist, artistToArtistSummary, asArtistAlbumPairs, Playlist, PlaylistSummary, Rating, SimilarArtist, Track } from "../../src/music_service"; +import { artistImageURN, asGenre, asTrack, images, isValidImage, song, SubsonicGenericMusicLibrary } from "../../src/subsonic/generic"; +import { EMPTY, error, FAILURE, subsonicOK, ok } from "../subsonic.test"; +import Subsonic, { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; +import { asURLSearchParams } from "../../src/utils"; +import { b64Encode } from "../../src/b64"; + + +const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => pipe( + coverArt, + O.fromNullable, + O.map(it => it.resource.split(":")[1]), + O.getOrElseW(() => "") +) + +const asAlbumJson = ( + artist: { id: string | undefined; name: string | undefined }, + album: AlbumSummary, + tracks: Track[] = [] +) => ({ + id: album.id, + parent: artist.id, + isDir: "true", + title: album.name, + name: album.name, + album: album.name, + artist: artist.name, + genre: album.genre?.name, + coverArt: maybeIdFromCoverArtUrn(album.coverArt), + duration: "123", + playCount: "4", + year: album.year, + created: "2021-01-07T08:19:55.834207205Z", + artistId: artist.id, + songCount: "19", + isVideo: false, + song: tracks.map(asSongJson), +}); + +const asSongJson = (track: Track) => ({ + id: track.id, + parent: track.album.id, + title: track.name, + album: track.album.name, + artist: track.artist.name, + track: track.number, + genre: track.genre?.name, + isDir: "false", + coverArt: maybeIdFromCoverArtUrn(track.coverArt), + created: "2004-11-08T23:36:11", + duration: track.duration, + bitRate: 128, + size: "5624132", + suffix: "mp3", + contentType: track.mimeType, + isVideo: "false", + path: "ACDC/High voltage/ACDC - The Jack.mp3", + albumId: track.album.id, + artistId: track.artist.id, + type: "music", + starred: track.rating.love ? "sometime" : undefined, + userRating: track.rating.stars, + year: "", +}); + + +const asSimilarArtistJson = (similarArtist: SimilarArtist) => { + if (similarArtist.inLibrary) + return { + id: similarArtist.id, + name: similarArtist.name, + albumCount: 3, + }; + else + return { + id: -1, + name: similarArtist.name, + albumCount: 3, + }; +}; + +const getArtistInfoJson = ( + artist: Artist, + images: images = { + smallImageUrl: undefined, + mediumImageUrl: undefined, + largeImageUrl: undefined, + } +) => + subsonicOK({ + artistInfo2: { + ...images, + similarArtist: artist.similarArtists.map(asSimilarArtistJson), + }, + }); + +const getAlbumListJson = (albums: [Artist, Album][]) => + subsonicOK({ + albumList2: { + album: albums.map(([artist, album]) => asAlbumJson(artist, album)), + }, + }); + +type ArtistExtras = { artistImageUrl: string | undefined } + +const asArtistJson = ( + artist: Artist, + extras: ArtistExtras = { artistImageUrl: undefined } +) => ({ + id: artist.id, + name: artist.name, + albumCount: artist.albums.length, + album: artist.albums.map((it) => asAlbumJson(artist, it)), + ...extras, +}); + +const getArtistJson = (artist: Artist, extras: ArtistExtras = { artistImageUrl: undefined }) => + subsonicOK({ + artist: asArtistJson(artist, extras), + }); + +const asGenreJson = (genre: { name: string; albumCount: number }) => ({ + songCount: 1475, + albumCount: genre.albumCount, + value: genre.name, +}); + +const getGenresJson = (genres: { name: string; albumCount: number }[]) => + subsonicOK({ + genres: { + genre: genres.map(asGenreJson), + }, + }); + +const getAlbumJson = (artist: Artist, album: Album, tracks: Track[]) => + subsonicOK({ album: asAlbumJson(artist, album, tracks) }); + +const getSongJson = (track: Track) => subsonicOK({ song: asSongJson(track) }); + + +const getSimilarSongsJson = (tracks: Track[]) => + subsonicOK({ similarSongs2: { song: tracks.map(asSongJson) } }); + +const getTopSongsJson = (tracks: Track[]) => + subsonicOK({ topSongs: { song: tracks.map(asSongJson) } }); + +export type ArtistWithAlbum = { + artist: Artist; + album: Album; +}; + +const asPlaylistJson = (playlist: PlaylistSummary) => ({ + id: playlist.id, + name: playlist.name, + songCount: 1, + duration: 190, + public: true, + owner: "bob", + created: "2021-05-06T02:07:24.308007023Z", + changed: "2021-05-06T02:08:06Z", +}); + +const getPlayListsJson = (playlists: PlaylistSummary[]) => + subsonicOK({ + playlists: { + playlist: playlists.map(asPlaylistJson), + }, + }); + +const createPlayListJson = (playlist: PlaylistSummary) => + subsonicOK({ + playlist: asPlaylistJson(playlist), + }); + +const getPlayListJson = (playlist: Playlist) => + subsonicOK({ + playlist: { + id: playlist.id, + name: playlist.name, + songCount: playlist.entries.length, + duration: 627, + public: true, + owner: "bob", + created: "2021-05-06T02:07:30.460465988Z", + changed: "2021-05-06T02:40:04Z", + entry: playlist.entries.map((it) => ({ + id: it.id, + parent: "...", + isDir: false, + title: it.name, + album: it.album.name, + artist: it.artist.name, + track: it.number, + year: it.album.year, + genre: it.album.genre?.name, + coverArt: maybeIdFromCoverArtUrn(it.coverArt), + size: 123, + contentType: it.mimeType, + suffix: "mp3", + duration: it.duration, + bitRate: 128, + path: "...", + discNumber: 1, + created: "2019-09-04T04:07:00.138169924Z", + albumId: it.album.id, + artistId: it.artist.id, + type: "music", + isVideo: false, + starred: it.rating.love ? "sometime" : undefined, + userRating: it.rating.stars, + })), + }, + }); + +const getSearchResult3Json = ({ + artists, + albums, + tracks, +}: Partial<{ + artists: Artist[]; + albums: ArtistWithAlbum[]; + tracks: Track[]; +}>) => + subsonicOK({ + searchResult3: { + artist: (artists || []).map((it) => asArtistJson({ ...it, albums: [] })), + album: (albums || []).map((it) => asAlbumJson(it.artist, it.album, [])), + song: (tracks || []).map((it) => asSongJson(it)), + }, + }); + +const asArtistsJson = (artists: Artist[]) => { + const as: Artist[] = []; + const bs: Artist[] = []; + const cs: Artist[] = []; + const rest: Artist[] = []; + artists.forEach((it) => { + const firstChar = it.name.toLowerCase()[0]; + switch (firstChar) { + case "a": + as.push(it); + break; + case "b": + bs.push(it); + break; + case "c": + cs.push(it); + break; + default: + rest.push(it); + break; + } + }); + + const asArtistSummary = (artist: Artist) => ({ + id: artist.id, + name: artist.name, + albumCount: artist.albums.length, + }); + + return subsonicOK({ + artists: { + index: [ + { + name: "A", + artist: as.map(asArtistSummary), + }, + { + name: "B", + artist: bs.map(asArtistSummary), + }, + { + name: "C", + artist: cs.map(asArtistSummary), + }, + { + name: "D-Z", + artist: rest.map(asArtistSummary), + }, + ], + }, + }); +}; + +describe("isValidImage", () => { + describe("when ends with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { + it("is dodgy", () => { + expect( + isValidImage("http://something/2a96cbd8b46e442fc41c2b86b821562f.png") + ).toEqual(false); + }); + }); + describe("when does not end with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { + it("is dodgy", () => { + expect(isValidImage("http://something/somethingelse.png")).toEqual(true); + expect( + isValidImage( + "http://something/2a96cbd8b46e442fc41c2b86b821562f.png?withsomequerystring=true" + ) + ).toEqual(true); + }); + }); +}); + +describe("artistImageURN", () => { + describe("when artist URL is", () => { + describe("a valid external URL", () => { + it("should return an external URN", () => { + expect( + artistImageURN({ artistId: "someArtistId", artistImageURL: "http://example.com/image.jpg" }) + ).toEqual({ system: "external", resource: "http://example.com/image.jpg" }); + }); + }); + + describe("an invalid external URL", () => { + describe("and artistId is valid", () => { + it("should return an external URN", () => { + expect( + artistImageURN({ + artistId: "someArtistId", + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + }) + ).toEqual({ system: "subsonic", resource: "art:someArtistId" }); + }); + }); + + describe("and artistId is -1", () => { + it("should return an error icon urn", () => { + expect( + artistImageURN({ + artistId: "-1", + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + }) + ).toBeUndefined(); + }); + }); + + describe("and artistId is undefined", () => { + it("should return an error icon urn", () => { + expect( + artistImageURN({ + artistId: undefined, + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + }) + ).toBeUndefined(); + }); + }); + }); + + describe("undefined", () => { + describe("and artistId is valid", () => { + it("should return artist art by artist id URN", () => { + expect(artistImageURN({ artistId: "someArtistId", artistImageURL: undefined })).toEqual({system:"subsonic", resource:"art:someArtistId"}); + }); + }); + + describe("and artistId is -1", () => { + it("should return error icon", () => { + expect(artistImageURN({ artistId: "-1", artistImageURL: undefined })).toBeUndefined(); + }); + }); + + describe("and artistId is undefined", () => { + it("should return error icon", () => { + expect(artistImageURN({ artistId: undefined, artistImageURL: undefined })).toBeUndefined(); + }); + }); + }); + }); +}); + +describe("asTrack", () => { + describe("when the song has no artistId", () => { + const album = anAlbum(); + const track = aTrack({ artist: { id: undefined, name: "Not in library so no id", image: undefined }}); + + it("should provide no artistId", () => { + const result = asTrack(album, { ...asSongJson(track) }); + expect(result.artist.id).toBeUndefined(); + expect(result.artist.name).toEqual("Not in library so no id"); + expect(result.artist.image).toBeUndefined(); + }); + }); + + describe("when the song has no artist name", () => { + const album = anAlbum(); + + it("should provide a ? to sonos", () => { + const result = asTrack(album, { id: '1' } as any as song); + expect(result.artist.id).toBeUndefined(); + expect(result.artist.name).toEqual("?"); + expect(result.artist.image).toBeUndefined(); + }); + }); + + describe("invalid rating.stars values", () => { + const album = anAlbum(); + const track = aTrack(); + + describe("a value greater than 5", () => { + it("should be returned as 0", () => { + const result = asTrack(album, { ...asSongJson(track), userRating: 6 }); + expect(result.rating.stars).toEqual(0); + }); + }); + + describe("a value less than 0", () => { + it("should be returned as 0", () => { + const result = asTrack(album, { ...asSongJson(track), userRating: -1 }); + expect(result.rating.stars).toEqual(0); + }); + }); + }); +}); + +describe("SubsonicGenericMusicLibrary", () => { + const mockRandomstring = jest.fn(); + const mockGET = jest.fn(); + const mockPOST = jest.fn(); + + const url = "http://127.0.0.22:4567"; + const username = `user1-${uuid()}`; + const password = `pass1-${uuid()}`; + const salt = "saltysalty"; + + const streamClientApplication = jest.fn(); + const subsonic = new Subsonic(url, streamClientApplication) + const generic = new SubsonicGenericMusicLibrary( + subsonic, + { + username, + password, + type: 'subsonic', + bearer: undefined + } + ); + + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + + randomstring.generate = mockRandomstring; + axios.get = mockGET; + axios.post = mockPOST; + + mockRandomstring.mockReturnValue(salt); + }); + + const authParams = { + u: username, + v: "1.16.1", + c: "bonob", + t: t(password, salt), + s: salt, + }; + + const authParamsPlusJson = { + ...authParams, + f: "json", + }; + + const headers = { + "User-Agent": "bonob", + }; + + describe("getting genres", () => { + describe("when there are none", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(getGenresJson([])))); + }); + + it("should return empty array", async () => { + const result = await generic.genres(); + + expect(result).toEqual([]); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + + describe("when there is only 1 that has an albumCount > 0", () => { + const genres = [ + { name: "genre1", albumCount: 1 }, + { name: "genreWithNoAlbums", albumCount: 0 }, + ]; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson(genres))) + ); + }); + + it("should return them alphabetically sorted", async () => { + const result = await generic.genres(); + + expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + + describe("when there are many that have an albumCount > 0", () => { + const genres = [ + { name: "g1", albumCount: 1 }, + { name: "g2", albumCount: 1 }, + { name: "g3", albumCount: 1 }, + { name: "g4", albumCount: 1 }, + { name: "someGenreWithNoAlbums", albumCount: 0 }, + ]; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson(genres))) + ); + }); + + it("should return them alphabetically sorted", async () => { + const result = await generic.genres(); + + expect(result).toEqual([ + { id: b64Encode("g1"), name: "g1" }, + { id: b64Encode("g2"), name: "g2" }, + { id: b64Encode("g3"), name: "g3" }, + { id: b64Encode("g4"), name: "g4" }, + ]); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + }); + + describe("getting an artist", () => { + describe("when the artist exists", () => { + describe("and has many similar artists", () => { + const album1: Album = anAlbum({ genre: asGenre("Pop") }); + + const album2: Album = anAlbum({ genre: asGenre("Pop") }); + + const artist: Artist = anArtist({ + albums: [album1, album2], + similarArtists: [ + aSimilarArtist({ + id: "similar1.id", + name: "similar1", + inLibrary: true, + }), + aSimilarArtist({ id: "-1", name: "similar2", inLibrary: false }), + aSimilarArtist({ + id: "similar3.id", + name: "similar3", + inLibrary: true, + }), + aSimilarArtist({ id: "-1", name: "similar4", inLibrary: false }), + ], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return the similar artists", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: `${artist.id}`, + name: artist.name, + image: { system:"subsonic", resource:`art:${artist.id}` }, + albums: artist.albums, + similarArtists: artist.similarArtists, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has one similar artist", () => { + const album1: Album = anAlbum({ genre: asGenre("G1") }); + + const album2: Album = anAlbum({ genre: asGenre("G2") }); + + const artist: Artist = anArtist({ + albums: [album1, album2], + similarArtists: [ + aSimilarArtist({ + id: "similar1.id", + name: "similar1", + inLibrary: true, + }), + ], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return the similar artists", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system:"subsonic", resource:`art:${artist.id}` }, + albums: artist.albums, + similarArtists: artist.similarArtists, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has no similar artists", () => { + const album1: Album = anAlbum({ genre: asGenre("Jock") }); + + const album2: Album = anAlbum({ genre: asGenre("Mock") }); + + const artist: Artist = anArtist({ + albums: [album1, album2], + similarArtists: [], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return the similar artists", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system:"subsonic", resource: `art:${artist.id}` }, + albums: artist.albums, + similarArtists: artist.similarArtists, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has dodgy looking artist image uris", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl}))) + ); + }); + + it("should return remove the dodgy looking image uris and return urn for artist:id", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { + system: "subsonic", + resource: `art:${artist.id}`, + }, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has a good external image uri from getArtist route", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: 'http://example.com:1234/good/looking/image.png' }))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl }))) + ); + }); + + it("should use the external url", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system: "external", resource: 'http://example.com:1234/good/looking/image.png' }, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has a good large external image uri from getArtistInfo route", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: 'http://example.com:1234/good/large/image.png' }))) + ); + }); + + it("should use the external url", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system: "external", resource: 'http://example.com:1234/good/large/image.png' }, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + + describe("and has a good medium external image uri from getArtistInfo route", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: 'http://example.com:1234/good/medium/image.png', largeImageUrl: dodgyImageUrl }))) + ); + }); + + it("should use the external url", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system:"external", resource: 'http://example.com:1234/good/medium/image.png' }, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has multiple albums", () => { + const album1: Album = anAlbum({ genre: asGenre("Pop") }); + + const album2: Album = anAlbum({ genre: asGenre("Flop") }); + + const artist: Artist = anArtist({ + albums: [album1, album2], + similarArtists: [], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return it", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: artist.image, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has only 1 album", () => { + const album: Album = anAlbum({ genre: asGenre("Pop") }); + + const artist: Artist = anArtist({ + albums: [album], + similarArtists: [], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return it", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: artist.image, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has no albums", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return it", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: artist.image, + albums: [], + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + }); + }); + + describe("getting artists", () => { + describe("when subsonic flavour is generic", () => { + describe("when there are indexes, but no artists", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + subsonicOK({ + artists: { + index: [ + { + name: "#", + }, + { + name: "A", + }, + { + name: "B", + }, + ], + }, + }) + ) + ) + ); + }); + + it("should return empty", async () => { + const artists = await generic.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( + subsonicOK({ + artists: {}, + }) + ) + ) + ); + }); + + it("should return empty", async () => { + const artists = await generic.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(asArtistsJson))); + }); + + it("should return the single artist", async () => { + const artists = await generic.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(asArtistsJson(artists))) + ); + }); + + it("should return all the artists", async () => { + const artists = await generic.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(asArtistsJson(artists))) + ); + }); + + it("should return only the correct page of artists", async () => { + const artists = await generic.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, + }); + }); + }); + }); + }); + + // todo: put this test back + // 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: [ + // artistSummaryFromNDArtist(ndArtist1), + // artistSummaryFromNDArtist(ndArtist2), + // artistSummaryFromNDArtist(ndArtist3), + // artistSummaryFromNDArtist(ndArtist4), + // ], + // total: 4, + // }); + + // 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 start index 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: undefined })); + + // 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" + // }), + // 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}`, + // }, + // }); + // }); + // }); + + // }); + }); + + describe("getting albums", () => { + describe("filtering", () => { + const album1 = anAlbum({ id: "album1", genre: asGenre("Pop") }); + const album2 = anAlbum({ id: "album2", genre: asGenre("Rock") }); + const album3 = anAlbum({ id: "album3", genre: asGenre("Pop") }); + const album4 = anAlbum({ id: "album4", genre: asGenre("Pop") }); + const album5 = anAlbum({ id: "album5", genre: asGenre("Pop") }); + + const artist = anArtist({ + albums: [album1, album2, album3, album4, album5], + }); + + describe("by genre", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist, album1], + // album2 is not Pop + [artist, album3], + ]) + ) + ) + ); + }); + + it("should map the 64 encoded genre back into the subsonic genre", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + genre: b64Encode("Pop"), + type: "byGenre", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album1, album3].map(albumToAlbumSummary), + total: 2, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "byGenre", + genre: "Pop", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("by newest", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist, album3], + [artist, album2], + [artist, album1], + ]) + ) + ) + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "recentlyAdded", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album3, album2, album1].map(albumToAlbumSummary), + total: 3, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "newest", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("by recently played", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist, album3], + [artist, album2], + // album1 never played + ]) + ) + ) + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "recentlyPlayed", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album3, album2].map(albumToAlbumSummary), + total: 2, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "recent", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("by frequently played", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce( + () => + // album1 never played + Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) + // album3 never played + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { _index: 0, _count: 100, type: "mostPlayed" }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album2].map(albumToAlbumSummary), + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "frequent", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("by starred", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce( + () => + // album1 never played + Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) + // album3 never played + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { _index: 0, _count: 100, type: "starred" }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album2].map(albumToAlbumSummary), + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "highest", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + }); + + describe("when the artist has only 1 album", () => { + const artist = anArtist({ + name: "one hit wonder", + albums: [anAlbum({ genre: asGenre("Pop") })], + }); + const artists = [artist]; + const albums = artists.flatMap((artist) => artist.albums); + + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) + ); + }); + + it("should return the album", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: albums, + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("when the only artist has no albums", () => { + const artist = anArtist({ + name: "no hit wonder", + albums: [], + }); + const artists = [artist]; + const albums = artists.flatMap((artist) => artist.albums); + + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) + ); + }); + + it("should return the album", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: albums, + total: 0, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("when there are 6 albums in total", () => { + const genre1 = asGenre("genre1"); + const genre2 = asGenre("genre2"); + const genre3 = asGenre("genre3"); + + const artist1 = anArtist({ + name: "abba", + albums: [ + anAlbum({ name: "album1", genre: genre1 }), + anAlbum({ name: "album2", genre: genre2 }), + anAlbum({ name: "album3", genre: genre3 }), + ], + }); + const artist2 = anArtist({ + name: "babba", + albums: [ + anAlbum({ name: "album4", genre: genre1 }), + anAlbum({ name: "album5", genre: genre2 }), + anAlbum({ name: "album6", genre: genre3 }), + ], + }); + const artists = [artist1, artist2]; + const albums = artists.flatMap((artist) => artist.albums); + + describe("querying for all of them", () => { + it("should return all of them with corrent paging information", async () => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) + ); + + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: albums, + total: 6, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("querying for a page of them", () => { + it("should return the page with the corrent paging information", async () => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, artist1.albums[2]!], + [artist2, artist2.albums[0]!], + // due to pre-fetch will get next 2 albums also + [artist2, artist2.albums[1]!], + [artist2, artist2.albums[2]!], + ]) + ) + ) + ); + + const q: AlbumQuery = { + _index: 2, + _count: 2, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [artist1.albums[2], artist2.albums[0]], + total: 6, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: 2, + }), + headers, + }); + }); + }); + }); + + describe("when the number of albums reported by getArtists does not match that of getAlbums", () => { + const genre = asGenre("lofi"); + + const album1 = anAlbum({ name: "album1", genre }); + const album2 = anAlbum({ name: "album2", genre }); + const album3 = anAlbum({ name: "album3", genre }); + const album4 = anAlbum({ name: "album4", genre }); + const album5 = anAlbum({ name: "album5", genre }); + + // the artists have 5 albums in the getArtists endpoint + const artist1 = anArtist({ + albums: [album1, album2, album3, album4], + }); + const artist2 = anArtist({ + albums: [album5], + }); + const artists = [artist1, artist2]; + + describe("when the number of albums returned from getAlbums is less the number of albums in the getArtists endpoint", () => { + describe("when the query comes back on 1 page", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + [artist1, album3], + // album4 is missing from the albums end point for some reason + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should return the page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album1, album2, album3, album5], + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + + describe("when the query is for the first page", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + // album3 & album5 is returned due to the prefetch + [artist1, album3], + // album4 is missing from the albums end point for some reason + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should filter out the pre-fetched albums", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 2, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album1, album2], + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + + describe("when the query is for the last page only", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + // album1 is on the first page + // album2 is on the first page + [artist1, album3], + // album4 is missing from the albums end point for some reason + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should return the last page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 2, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album3, album5], + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + }); + + describe("when the number of albums returned from getAlbums is more than the number of albums in the getArtists endpoint", () => { + describe("when the query comes back on 1 page", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + asArtistsJson([ + // artist1 has lost 2 albums on the getArtists end point + { ...artist1, albums: [album1, album2] }, + artist2, + ]) + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + [artist1, album3], + [artist1, album4], + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should return the page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album1, album2, album3, album4, album5], + total: 5, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + + describe("when the query is for the first page", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + asArtistsJson([ + // artist1 has lost 2 albums on the getArtists end point + { ...artist1, albums: [album1, album2] }, + artist2, + ]) + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + [artist1, album3], + [artist1, album4], + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should filter out the pre-fetched albums", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 2, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album1, album2], + total: 5, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + + describe("when the query is for the last page only", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + asArtistsJson([ + // artist1 has lost 2 albums on the getArtists end point + { ...artist1, albums: [album1, album2] }, + artist2, + ]) + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album3], + [artist1, album4], + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should return the last page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 2, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album3, album4, album5], + total: 5, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + }); + }); + }); + + describe("getting an album", () => { + describe("when it exists", () => { + const genre = asGenre("Pop"); + + const album = anAlbum({ genre }); + + const artist = anArtist({ albums: [album] }); + + const tracks = [ + aTrack({ artist, album, genre }), + aTrack({ artist, album, genre }), + aTrack({ artist, album, genre }), + aTrack({ artist, album, genre }), + ]; + + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); + }); + + it("should return the album", async () => { + const result = await generic.album(album.id); + + expect(result).toEqual(album); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + }); + + describe("getting tracks", () => { + describe("for an album", () => { + describe("when the album has multiple tracks, some of which are rated", () => { + const hipHop = asGenre("Hip-Hop"); + const tripHop = asGenre("Trip-Hop"); + + const album = anAlbum({ id: "album1", name: "Burnin", genre: hipHop }); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + + const track1 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: hipHop, + rating: { + love: true, + stars: 3, + }, + }); + const track2 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: hipHop, + rating: { + love: false, + stars: 0, + }, + }); + const track3 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: tripHop, + rating: { + love: true, + stars: 5, + }, + }); + const track4 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: tripHop, + rating: { + love: false, + stars: 1, + }, + }); + + const tracks = [track1, track2, track3, track4]; + + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); + }); + + it("should return the album", async () => { + const result = await generic.tracks(album.id); + + expect(result).toEqual([track1, track2, track3, track4]); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + + describe("when the album has only 1 track", () => { + const flipFlop = asGenre("Flip-Flop"); + + const album = anAlbum({ + id: "album1", + name: "Burnin", + genre: flipFlop, + }); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: flipFlop, + }); + + const tracks = [track]; + + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); + }); + + it("should return the album", async () => { + const result = await generic.tracks(album.id); + + expect(result).toEqual([track]); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + + describe("when the album has only no tracks", () => { + const album = anAlbum({ id: "album1", name: "Burnin" }); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + + const tracks: Track[] = []; + + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); + }); + + it("should empty array", async () => { + const result = await generic.tracks(album.id); + + expect(result).toEqual([]); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + }); + + describe("a single track", () => { + const pop = asGenre("Pop"); + + const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + + describe("that is starred", () => { + it("should return the track", async () => { + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + rating: { + love: true, + stars: 4, + }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await generic.track(track.id); + + expect(result).toEqual({ + ...track, + rating: { love: true, stars: 4 }, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: track.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + + describe("that is not starred", () => { + it("should return the track", async () => { + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + rating: { + love: false, + stars: 0, + }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await generic.track(track.id); + + expect(result).toEqual({ + ...track, + rating: { love: false, stars: 0 }, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: track.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + }); + }); + + describe("streaming a track", () => { + const trackId = uuid(); + const genre = aGenre("foo"); + + const album = anAlbum({ genre }); + const artist = anArtist({ + albums: [album] + }); + const track = aTrack({ + id: trackId, + album: albumToAlbumSummary(album), + artist: artistToArtistSummary(artist), + genre, + }); + + describe("content-range, accept-ranges or content-length", () => { + beforeEach(() => { + streamClientApplication.mockReturnValue("bonob"); + }); + + describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => { + it("should return undefined values", async () => { + const stream = { + pipe: jest.fn(), + }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + }, + data: stream, + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.stream({ trackId, range: undefined }); + + expect(result.headers).toEqual({ + "content-type": "audio/mpeg", + "content-length": undefined, + "content-range": undefined, + "accept-ranges": undefined, + }); + }); + }); + + describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => { + it("should return undefined values", async () => { + const stream = { + pipe: jest.fn(), + }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + "content-length": undefined, + "content-range": undefined, + "accept-ranges": undefined, + }, + data: stream, + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.stream({ trackId, range: undefined }); + + expect(result.headers).toEqual({ + "content-type": "audio/mpeg", + "content-length": undefined, + "content-range": undefined, + "accept-ranges": undefined, + }); + }); + }); + + describe("with no range specified", () => { + describe("navidrome returns a 200", () => { + it("should return the content", async () => { + const stream = { + pipe: jest.fn(), + }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + "content-length": "1667", + "content-range": "-200", + "accept-ranges": "bytes", + "some-other-header": "some-value", + }, + data: stream, + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.stream({ trackId, range: undefined }); + + expect(result.headers).toEqual({ + "content-type": "audio/mpeg", + "content-length": "1667", + "content-range": "-200", + "accept-ranges": "bytes", + }); + expect(result.stream).toEqual(stream); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { + params: asURLSearchParams({ + ...authParams, + id: trackId, + }), + headers: { + "User-Agent": "bonob", + }, + responseType: "stream", + }); + }); + }); + + describe("navidrome returns something other than a 200", () => { + it("should fail", async () => { + const trackId = "track123"; + + const streamResponse = { + status: 400, + headers: { + 'content-type': 'text/html', + 'content-length': '33' + } + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + return expect( + generic.stream({ trackId, range: undefined }) + ).rejects.toEqual(`Subsonic failed with a 400 status`); + }); + }); + + describe("io exception occurs", () => { + it("should fail", async () => { + const trackId = "track123"; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.reject("IO error occured")); + + return expect( + generic.stream({ trackId, range: undefined }) + ).rejects.toEqual(`Subsonic failed with: IO error occured`); + }); + }); + }); + + describe("with range specified", () => { + it("should send the range to navidrome", async () => { + const stream = { + pipe: jest.fn(), + }; + + const range = "1000-2000"; + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/flac", + "content-length": "66", + "content-range": "100-200", + "accept-ranges": "none", + "some-other-header": "some-value", + }, + data: stream, + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.stream({ trackId, range }); + + expect(result.headers).toEqual({ + "content-type": "audio/flac", + "content-length": "66", + "content-range": "100-200", + "accept-ranges": "none", + }); + expect(result.stream).toEqual(stream); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { + params: asURLSearchParams({ + ...authParams, + id: trackId, + }), + headers: { + "User-Agent": "bonob", + Range: range, + }, + responseType: "stream", + }); + }); + }); + }); + + describe("when navidrome has a custom StreamClientApplication registered", () => { + describe("when no range specified", () => { + it("should user the custom StreamUserAgent when calling navidrome", async () => { + const clientApplication = `bonob-${uuid()}`; + streamClientApplication.mockReturnValue(clientApplication); + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + }, + data: Buffer.from("the track", "ascii"), + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, [track]))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + await generic.stream({ trackId, range: undefined }); + + expect(streamClientApplication).toHaveBeenCalledWith(track); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { + params: asURLSearchParams({ + ...authParams, + id: trackId, + c: clientApplication, + }), + headers: { + "User-Agent": "bonob", + }, + responseType: "stream", + }); + }); + }); + + describe("when range specified", () => { + it("should user the custom StreamUserAgent when calling navidrome", async () => { + const range = "1000-2000"; + const clientApplication = `bonob-${uuid()}`; + streamClientApplication.mockReturnValue(clientApplication); + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + }, + data: Buffer.from("the track", "ascii"), + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, [track]))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + await generic.stream({ trackId, range }); + + expect(streamClientApplication).toHaveBeenCalledWith(track); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { + params: asURLSearchParams({ + ...authParams, + id: trackId, + c: clientApplication, + }), + headers: { + "User-Agent": "bonob", + Range: range, + }, + responseType: "stream", + }); + }); + }); + }); + }); + + describe("fetching cover art", () => { + describe("fetching album art", () => { + describe("when no size is specified", () => { + it("should fetch the image", async () => { + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; + const coverArtId = "someCoverArt"; + const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; + + mockGET + + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.coverArt(coverArtURN); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + }), + headers, + responseType: "arraybuffer", + }); + }); + }); + + describe("when size is specified", () => { + it("should fetch the image", async () => { + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; + const coverArtId = uuid(); + const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` } + const size = 1879; + + mockGET + + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.coverArt(coverArtURN, size); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + size, + }), + headers, + responseType: "arraybuffer", + }); + }); + }); + + describe("when an unexpected error occurs", () => { + it("should return undefined", async () => { + const size = 1879; + + mockGET + + .mockImplementationOnce(() => Promise.reject("BOOOM")); + + const result = await generic.coverArt({ system: "external", resource: "http://localhost:404" }, size); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe("fetching cover art", () => { + describe("when urn.resource is not subsonic", () => { + it("should be undefined", async () => { + const covertArtURN = { system: "notSubsonic", resource: `art:${uuid()}` }; + + mockGET + ; + + const result = await generic.coverArt(covertArtURN, 190); + + expect(result).toBeUndefined(); + }); + }); + + describe("when no size is specified", () => { + it("should fetch the image", async () => { + const coverArtId = uuid() + const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; + + mockGET + + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.coverArt(covertArtURN); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getCoverArt`, + { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + }), + headers, + responseType: "arraybuffer", + } + ); + }); + + describe("and an error occurs fetching the uri", () => { + it("should return undefined", async () => { + const coverArtId = uuid() + const covertArtURN = { system:"subsonic", resource: `art:${coverArtId}` }; + + mockGET + + .mockImplementationOnce(() => Promise.reject("BOOOM")); + + const result = await generic.coverArt(covertArtURN); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe("when size is specified", () => { + const size = 189; + + it("should fetch the image", async () => { + const coverArtId = uuid() + const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; + + mockGET + + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.coverArt(covertArtURN, size); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getCoverArt`, + { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + size + }), + headers, + responseType: "arraybuffer", + } + ); + }); + + describe("and an error occurs fetching the uri", () => { + it("should return undefined", async () => { + const coverArtId = uuid() + const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; + + mockGET + + .mockImplementationOnce(() => Promise.reject("BOOOM")); + + const result = await generic.coverArt(covertArtURN, size); + + expect(result).toBeUndefined(); + }); + }); + }); + }); + }); + + describe("rate", () => { + const trackId = uuid(); + + const rate = (trackId: string, rating: Rating) => generic.rate(trackId, rating); + + const artist = anArtist(); + const album = anAlbum({ id: "album1", name: "Burnin", genre: POP }); + + describe("rating a track", () => { + describe("loving a track that isnt already loved", () => { + it("should mark the track as loved", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: false, stars: 0 }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/star`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + }); + }); + }); + + describe("unloving a track that is loved", () => { + it("should mark the track as loved", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 0 }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: false, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/unstar`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + }); + }); + }); + + describe("loving a track that is already loved", () => { + it("shouldn't do anything", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 0 }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledTimes(2); + }); + }); + + describe("rating a track with a different rating", () => { + it("should add the new rating", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: false, stars: 0 }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: false, stars: 3 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/setRating`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + rating: 3, + }), + headers, + }); + }); + }); + + describe("rating a track with the same rating it already has", () => { + it("shouldn't do anything", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 3 }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await rate(trackId, { love: true, stars: 3 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledTimes(2); + }); + }); + + describe("loving and rating a track", () => { + it("should return true", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 3 }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: false, stars: 5 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/unstar`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + }); + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/setRating`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + rating: 5, + }), + headers, + }); + }); + }); + + describe("invalid star values", () => { + describe("stars of -1", () => { + it("should return false", async () => { + mockGET; + + const result = await rate(trackId, { love: true, stars: -1 }); + expect(result).toEqual(false); + }); + }); + + describe("stars of 6", () => { + it("should return false", async () => { + mockGET; + + const result = await rate(trackId, { love: true, stars: -1 }); + expect(result).toEqual(false); + }); + }); + }); + + describe("when fails", () => { + it("should return false", async () => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(FAILURE))) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(false); + }); + }); + }); + }); + + describe("scrobble", () => { + describe("when succeeds", () => { + it("should return true", async () => { + const id = uuid(); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.scrobble(id); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + submission: true, + }), + headers, + }); + }); + }); + + describe("when fails", () => { + it("should return false", async () => { + const id = uuid(); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve({ + status: 500, + data: {}, + }) + ); + + const result = await generic.scrobble(id); + + expect(result).toEqual(false); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + submission: true, + }), + headers, + }); + }); + }); + }); + + describe("nowPlaying", () => { + describe("when succeeds", () => { + it("should return true", async () => { + const id = uuid(); + + mockGET + + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.nowPlaying(id); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + submission: false, + }), + headers, + }); + }); + }); + + describe("when fails", () => { + it("should return false", async () => { + const id = uuid(); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve({ + status: 500, + data: {}, + }) + ); + + const result = await generic.nowPlaying(id); + + expect(result).toEqual(false); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + submission: false, + }), + headers, + }); + }); + }); + }); + + describe("searchArtists", () => { + describe("when there is 1 search results", () => { + it("should return true", async () => { + const artist1 = anArtist({ name: "foo woo" }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ artists: [artist1] }))) + ); + + const result = await generic.searchArtists("foo"); + + expect(result).toEqual([artistToArtistSummary(artist1)]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 20, + albumCount: 0, + songCount: 0, + query: "foo", + }), + headers, + }); + }); + }); + + describe("when there are many search results", () => { + it("should return true", async () => { + const artist1 = anArtist({ name: "foo woo" }); + const artist2 = anArtist({ name: "foo choo" }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok(getSearchResult3Json({ artists: [artist1, artist2] })) + ) + ); + + const result = await generic.searchArtists("foo"); + + expect(result).toEqual([ + artistToArtistSummary(artist1), + artistToArtistSummary(artist2), + ]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 20, + albumCount: 0, + songCount: 0, + query: "foo", + }), + headers, + }); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) + ); + + const result = await generic.searchArtists("foo"); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 20, + albumCount: 0, + songCount: 0, + query: "foo", + }), + headers, + }); + }); + }); + }); + + describe("searchAlbums", () => { + describe("when there is 1 search results", () => { + it("should return true", async () => { + const album = anAlbum({ + name: "foo woo", + genre: { id: b64Encode("pop"), name: "pop" }, + }); + const artist = anArtist({ name: "#1", albums: [album] }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok(getSearchResult3Json({ albums: [{ artist, album }] })) + ) + ); + + const result = await generic.searchAlbums("foo"); + + expect(result).toEqual([albumToAlbumSummary(album)]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "foo", + }), + headers, + }); + }); + }); + + describe("when there are many search results", () => { + it("should return true", async () => { + const album1 = anAlbum({ + name: "album1", + genre: { id: b64Encode("pop"), name: "pop" }, + }); + const artist1 = anArtist({ name: "artist1", albums: [album1] }); + + const album2 = anAlbum({ + name: "album2", + genre: { id: b64Encode("pop"), name: "pop" }, + }); + const artist2 = anArtist({ name: "artist2", albums: [album2] }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + getSearchResult3Json({ + albums: [ + { artist: artist1, album: album1 }, + { artist: artist2, album: album2 }, + ], + }) + ) + ) + ); + + const result = await generic.searchAlbums("moo"); + + expect(result).toEqual([ + albumToAlbumSummary(album1), + albumToAlbumSummary(album2), + ]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "moo", + }), + headers, + }); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ albums: [] }))) + ); + + const result = await generic.searchAlbums("foo"); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "foo", + }), + headers, + }); + }); + }); + }); + + describe("searchSongs", () => { + describe("when there is 1 search results", () => { + it("should return true", async () => { + const pop = asGenre("Pop"); + + const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ tracks: [track] }))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track)))) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await generic.searchTracks("foo"); + + expect(result).toEqual([track]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 0, + songCount: 20, + query: "foo", + }), + headers, + }); + }); + }); + + describe("when there are many search results", () => { + it("should return true", async () => { + const pop = asGenre("Pop"); + + const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist1 = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album1], + }); + const track1 = aTrack({ + id: "track1", + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + genre: pop, + }); + + const album2 = anAlbum({ id: "album2", name: "Bobbin", genre: pop }); + const artist2 = anArtist({ + id: "artist2", + name: "Jane Marley", + albums: [album2], + }); + const track2 = aTrack({ + id: "track2", + artist: artistToArtistSummary(artist2), + album: albumToAlbumSummary(album2), + genre: pop, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + getSearchResult3Json({ + tracks: [track1, track2], + }) + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track1))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track2))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist2, album2, []))) + ); + + const result = await generic.searchTracks("moo"); + + expect(result).toEqual([track1, track2]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 0, + songCount: 20, + query: "moo", + }), + headers, + }); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ tracks: [] }))) + ); + + const result = await generic.searchTracks("foo"); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 0, + songCount: 20, + query: "foo", + }), + headers, + }); + }); + }); + }); + + describe("playlists", () => { + describe("getting playlists", () => { + describe("when there is 1 playlist results", () => { + it("should return it", async () => { + const playlist = aPlaylistSummary(); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson([playlist]))) + ); + + const result = await generic.playlists(); + + expect(result).toEqual([playlist]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + + describe("when there are many playlists", () => { + it("should return them", async () => { + const playlist1 = aPlaylistSummary(); + const playlist2 = aPlaylistSummary(); + const playlist3 = aPlaylistSummary(); + const playlists = [playlist1, playlist2, playlist3]; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson(playlists))) + ); + + const result = await generic.playlists(); + + expect(result).toEqual(playlists); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + + describe("when there are no playlists", () => { + it("should return []", async () => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson([]))) + ); + + const result = await generic.playlists(); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + }); + + describe("getting a single playlist", () => { + describe("when there is no playlist with the id", () => { + it("should raise error", async () => { + const id = "id404"; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(error("70", "data not found"))) + ); + + return expect( + generic.playlist(id) + ).rejects.toEqual("Subsonic error:data not found"); + }); + }); + + describe("when there is a playlist with the id", () => { + describe("and it has tracks", () => { + it("should return the playlist with entries", async () => { + const id = uuid(); + const name = "Great Playlist"; + const artist1 = anArtist(); + const album1 = anAlbum({ + artistId: artist1.id, + artistName: artist1.name, + genre: POP, + }); + const track1 = aTrack({ + genre: POP, + number: 66, + coverArt: album1.coverArt, + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + }); + + const artist2 = anArtist(); + const album2 = anAlbum({ + artistId: artist2.id, + artistName: artist2.name, + genre: ROCK, + }); + const track2 = aTrack({ + genre: ROCK, + number: 77, + coverArt: album2.coverArt, + artist: artistToArtistSummary(artist2), + album: albumToAlbumSummary(album2), + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + getPlayListJson({ + id, + name, + entries: [track1, track2], + }) + ) + ) + ); + + const result = await generic.playlist(id); + + expect(result).toEqual({ + id, + name, + entries: [ + { ...track1, number: 1 }, + { ...track2, number: 2 }, + ], + }); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + }), + headers, + }); + }); + }); + + describe("and it has no tracks", () => { + it("should return the playlist with empty entries", async () => { + const playlist = aPlaylist({ + entries: [], + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayListJson(playlist))) + ); + + const result = await generic.playlist(playlist.id); + + expect(result).toEqual(playlist); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: playlist.id, + }), + headers, + }); + }); + }); + }); + }); + + describe("creating a playlist", () => { + it("should create a playlist with the given name", async () => { + const name = "ThePlaylist"; + const id = uuid(); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(createPlayListJson({ id, name }))) + ); + + const result = await generic.createPlaylist(name); + + expect(result).toEqual({ id, name }); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/createPlaylist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + f: "json", + name, + }), + headers, + }); + }); + }); + + describe("deleting a playlist", () => { + it("should delete the playlist by id", async () => { + const id = "id-to-delete"; + + mockGET + + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.deletePlaylist(id); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/deletePlaylist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + }), + headers, + }); + }); + }); + + describe("editing playlists", () => { + describe("adding a track to a playlist", () => { + it("should add it", async () => { + const playlistId = uuid(); + const trackId = uuid(); + + mockGET + + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.addToPlaylist(playlistId, trackId); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + playlistId, + songIdToAdd: trackId, + }), + headers, + }); + }); + }); + + describe("removing a track from a playlist", () => { + it("should remove it", async () => { + const playlistId = uuid(); + const indicies = [6, 100, 33]; + + mockGET + + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.removeFromPlaylist(playlistId, indicies); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + playlistId, + songIndexToRemove: indicies, + }), + headers, + }); + }); + }); + }); + }); + + describe("similarSongs", () => { + describe("when there is one similar songs", () => { + it("should return it", async () => { + const id = "idWithTracks"; + const pop = asGenre("Pop"); + + const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist1 = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album1], + }); + + const track1 = aTrack({ + id: "track1", + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + genre: pop, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([track1]))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) + ); + + const result = await generic.similarSongs(id); + + expect(result).toEqual([track1]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { + params: asURLSearchParams({ + ...authParams, + f: "json", + id, + count: 50, + }), + headers, + }); + }); + }); + + describe("when there are similar songs", () => { + it("should return them", async () => { + const id = "idWithTracks"; + const pop = asGenre("Pop"); + + const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist1 = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album1], + }); + + const album2 = anAlbum({ id: "album2", name: "Walking", genre: pop }); + const artist2 = anArtist({ + id: "artist2", + name: "Bob Jane", + albums: [album2], + }); + + const track1 = aTrack({ + id: "track1", + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + genre: pop, + }); + const track2 = aTrack({ + id: "track2", + artist: artistToArtistSummary(artist2), + album: albumToAlbumSummary(album2), + genre: pop, + }); + const track3 = aTrack({ + id: "track3", + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + genre: pop, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([track1, track2, track3]))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist2, album2, []))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) + ); + + const result = await generic.similarSongs(id); + + expect(result).toEqual([track1, track2, track3]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { + params: asURLSearchParams({ + ...authParams, + f: "json", + id, + count: 50, + }), + headers, + }); + }); + }); + + describe("when there are no similar songs", () => { + it("should return []", async () => { + const id = "idWithNoTracks"; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([]))) + ); + + const result = await generic.similarSongs(id); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { + params: asURLSearchParams({ + ...authParams, + f: "json", + id, + count: 50, + }), + headers, + }); + }); + }); + + describe("when the id doesnt exist", () => { + it("should fail", async () => { + const id = "idThatHasAnError"; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(error("70", "data not found"))) + ); + + return expect( + generic.similarSongs(id) + ).rejects.toEqual("Subsonic error:data not found"); + }); + }); + }); + + describe("topSongs", () => { + describe("when there is one top song", () => { + it("should return it", async () => { + const artistId = "bobMarleyId"; + const artistName = "Bob Marley"; + const pop = asGenre("Pop"); + + const album1 = anAlbum({ name: "Burnin", genre: pop }); + const artist = anArtist({ + id: artistId, + name: artistName, + albums: [album1], + }); + + const track1 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album1), + genre: pop, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getTopSongsJson([track1]))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album1, []))) + ); + + const result = await generic.topSongs(artistId); + + expect(result).toEqual([track1]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { + params: asURLSearchParams({ + ...authParams, + f: "json", + artist: artistName, + count: 50, + }), + headers, + }); + }); + }); + + describe("when there are many top songs", () => { + it("should return them", async () => { + const artistId = "bobMarleyId"; + const artistName = "Bob Marley"; + + const album1 = anAlbum({ name: "Burnin", genre: POP }); + const album2 = anAlbum({ name: "Churning", genre: POP }); + + const artist = anArtist({ + id: artistId, + name: artistName, + albums: [album1, album2], + }); + + const track1 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album1), + genre: POP, + }); + + const track2 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album2), + genre: POP, + }); + + const track3 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album1), + genre: POP, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getTopSongsJson([track1, track2, track3]))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album1, []))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album2, []))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album1, []))) + ); + + const result = await generic.topSongs(artistId); + + expect(result).toEqual([track1, track2, track3]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { + params: asURLSearchParams({ + ...authParams, + f: "json", + artist: artistName, + count: 50, + }), + headers, + }); + }); + }); + + describe("when there are no similar songs", () => { + it("should return []", async () => { + const artistId = "bobMarleyId"; + const artistName = "Bob Marley"; + const pop = asGenre("Pop"); + + const album1 = anAlbum({ name: "Burnin", genre: pop }); + const artist = anArtist({ + id: artistId, + name: artistName, + albums: [album1], + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getTopSongsJson([]))) + ); + + + const result = await generic.topSongs(artistId); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { + params: asURLSearchParams({ + ...authParams, + f: "json", + artist: artistName, + count: 50, + }), + headers, + }); + }); + }); + }); +}); From 25857d7e5ac7228c2d74d502fceb4457c1b15df2 Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 5 Feb 2022 10:39:20 +1100 Subject: [PATCH 08/18] Extract ND into own class --- src/music_service.ts | 6 +++ src/subsonic.ts | 66 ++------------------------------ src/subsonic/generic.ts | 84 +++++++++++++++++++++++++++++++++++------ 3 files changed, 82 insertions(+), 74 deletions(-) diff --git a/src/music_service.ts b/src/music_service.ts index 8827ad0..e925e72 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -15,7 +15,13 @@ export class AuthFailure extends Error { } }; +export type IdName = { + id: string; + name: string; +}; + export type ArtistSummary = { + // todo: why can this be undefined? id: string | undefined; name: string; image: BUrn | undefined; diff --git a/src/subsonic.ts b/src/subsonic.ts index 1ea4765..86eaf8c 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -4,8 +4,6 @@ import { Md5 } from "ts-md5/dist/md5"; import { Credentials, MusicService, - Result, - ArtistQuery, MusicLibrary, Track, AuthFailure, @@ -19,8 +17,7 @@ import randomstring from "randomstring"; import { b64Encode, b64Decode } from "./b64"; import { axiosImageFetcher, ImageFetcher } from "./images"; import { asURLSearchParams } from "./utils"; -import { artistImageURN, SubsonicGenericMusicLibrary } from "./subsonic/generic"; - +import { artistImageURN, NaivdromeMusicLibrary, SubsonicGenericMusicLibrary } from "./subsonic/generic"; export const t = (password: string, s: string) => Md5.hashStr(`${password}${s}`); @@ -96,6 +93,7 @@ export type SubsonicCredentials = Credentials & { export const asToken = (credentials: SubsonicCredentials) => b64Encode(JSON.stringify(credentials)); + export const parseToken = (token: string): SubsonicCredentials => JSON.parse(b64Decode(token)); @@ -216,66 +214,10 @@ export class Subsonic implements MusicService { private libraryFor = ( credentials: SubsonicCredentials ): Promise => { - const genericSubsonic: SubsonicMusicLibrary = - new SubsonicGenericMusicLibrary(this, credentials); - if (credentials.type == "navidrome") { - return Promise.resolve({ - ...genericSubsonic, - flavour: () => "navidrome", - bearerToken: (credentials: Credentials) => - pipe( - TE.tryCatch( - () => - axios.post( - `${this.url}/auth/login`, - _.pick(credentials, "username", "password") - ), - () => new AuthFailure("Failed to get bearerToken") - ), - 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"), - })); - }, - }); + return Promise.resolve(new NaivdromeMusicLibrary(this, credentials)); } else { - return Promise.resolve(genericSubsonic); + return Promise.resolve(new SubsonicGenericMusicLibrary(this, credentials)); } }; } diff --git a/src/subsonic/generic.ts b/src/subsonic/generic.ts index 8ba8e18..810d536 100644 --- a/src/subsonic/generic.ts +++ b/src/subsonic/generic.ts @@ -3,13 +3,16 @@ import * as A from "fp-ts/Array"; import { pipe } from "fp-ts/lib/function"; import { ordString } from "fp-ts/lib/Ord"; import { inject } from 'underscore'; +import _ from "underscore"; import logger from "../logger"; import { b64Decode, b64Encode } from "../b64"; import { assertSystem, BUrn } from "../burn"; -import { Album, AlbumQuery, AlbumQueryType, AlbumSummary, Artist, ArtistQuery, Credentials, Genre, Rating, Result, slice2, Sortable, Track } from "../music_service"; -import Subsonic, { DODGY_IMAGE_NAME, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, USER_AGENT } from "../subsonic"; +import { Album, AlbumQuery, AlbumQueryType, AlbumSummary, Artist, ArtistQuery, ArtistSummary, AuthFailure, Credentials, Genre, IdName, Rating, Result, slice2, Sortable, Track } from "../music_service"; +import Subsonic, { artistSummaryFromNDArtist, DODGY_IMAGE_NAME, NDArtist, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, USER_AGENT } from "../subsonic"; +import axios from "axios"; +import { asURLSearchParams } from "../utils"; type album = { @@ -81,14 +84,6 @@ type artistInfo = images & { similarArtist: artist[]; }; -type IdName = { - id: string; - name: string; -}; - -type ArtistSummary = IdName & { - image: BUrn | undefined; -}; export type song = { id: string; @@ -272,9 +267,11 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { flavour = () => "subsonic"; - bearerToken = (_: Credentials) => TE.right(undefined); + bearerToken = (_: Credentials): TE.TaskEither => TE.right(undefined); - artists = (q: ArtistQuery): Promise> => + artists = async ( + q: ArtistQuery + ): Promise> => this.getArtists() .then(slice2(q)) .then(([page, total]) => ({ @@ -725,4 +722,67 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { results: albums.slice(0, q._count), total: albums.length == 500 ? total : (q._index || 0) + albums.length, })); +}; + +export class NaivdromeMusicLibrary extends SubsonicGenericMusicLibrary { + + constructor(subsonic: Subsonic, credentials: SubsonicCredentials) { + super(subsonic, credentials); + } + + flavour = () => "navidrome"; + + bearerToken = (credentials: Credentials): TE.TaskEither => + pipe( + TE.tryCatch( + () => + axios.post( + `${this.subsonic.url}/auth/login`, + _.pick(credentials, "username", "password") + ), + () => new AuthFailure("Failed to get bearerToken") + ), + 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, + }; + } + + const x: Promise> = axios + .get(`${this.subsonic.url}/api/artist`, { + params: asURLSearchParams(params), + headers: { + "User-Agent": USER_AGENT, + "x-nd-authorization": `Bearer ${this.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"), + })); + + return x; + } } \ No newline at end of file From ac266a3c46020e406b201d8620e6e48c9178a079 Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 5 Feb 2022 10:46:24 +1100 Subject: [PATCH 09/18] Add index.ts for subsonic --- src/subsonic/generic.ts | 3 +- src/{subsonic.ts => subsonic/index.ts} | 38 +++---------- src/subsonic/navidrome.ts | 22 ++++++++ tests/subsonic.test.ts | 75 ------------------------- tests/subsonic/navidrome.test.ts | 78 ++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 105 deletions(-) rename src/{subsonic.ts => subsonic/index.ts} (87%) create mode 100644 src/subsonic/navidrome.ts create mode 100644 tests/subsonic/navidrome.test.ts diff --git a/src/subsonic/generic.ts b/src/subsonic/generic.ts index 810d536..2526ebd 100644 --- a/src/subsonic/generic.ts +++ b/src/subsonic/generic.ts @@ -10,9 +10,10 @@ import { b64Decode, b64Encode } from "../b64"; import { assertSystem, BUrn } from "../burn"; import { Album, AlbumQuery, AlbumQueryType, AlbumSummary, Artist, ArtistQuery, ArtistSummary, AuthFailure, Credentials, Genre, IdName, Rating, Result, slice2, Sortable, Track } from "../music_service"; -import Subsonic, { artistSummaryFromNDArtist, DODGY_IMAGE_NAME, NDArtist, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, USER_AGENT } from "../subsonic"; +import Subsonic, { DODGY_IMAGE_NAME, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, USER_AGENT } from "../subsonic"; import axios from "axios"; import { asURLSearchParams } from "../utils"; +import { artistSummaryFromNDArtist, NDArtist } from "./navidrome"; type album = { diff --git a/src/subsonic.ts b/src/subsonic/index.ts similarity index 87% rename from src/subsonic.ts rename to src/subsonic/index.ts index 86eaf8c..4954ab2 100644 --- a/src/subsonic.ts +++ b/src/subsonic/index.ts @@ -1,23 +1,21 @@ import { taskEither as TE } from "fp-ts"; import { pipe } from "fp-ts/lib/function"; import { Md5 } from "ts-md5/dist/md5"; +import axios, { AxiosRequestConfig } from "axios"; +import randomstring from "randomstring"; +import _ from "underscore"; + import { Credentials, MusicService, MusicLibrary, Track, AuthFailure, - Sortable, - ArtistSummary, -} from "./music_service"; -import _ from "underscore"; - -import axios, { AxiosRequestConfig } from "axios"; -import randomstring from "randomstring"; -import { b64Encode, b64Decode } from "./b64"; -import { axiosImageFetcher, ImageFetcher } from "./images"; -import { asURLSearchParams } from "./utils"; -import { artistImageURN, NaivdromeMusicLibrary, SubsonicGenericMusicLibrary } from "./subsonic/generic"; +} from "../music_service"; +import { b64Encode, b64Decode } from "../b64"; +import { axiosImageFetcher, ImageFetcher } from "../images"; +import { asURLSearchParams } from "../utils"; +import { NaivdromeMusicLibrary, SubsonicGenericMusicLibrary } from "./generic"; export const t = (password: string, s: string) => Md5.hashStr(`${password}${s}`); @@ -63,12 +61,6 @@ export function isError( return (subsonicResponse as SubsonicError).error !== undefined; } -export type NDArtist = { - id: string; - name: string; - orderArtistName: string | undefined; - largeImageUrl: string | undefined; -}; @@ -104,18 +96,6 @@ export interface SubsonicMusicLibrary extends MusicLibrary { ): TE.TaskEither; } -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 class Subsonic implements MusicService { url: string; streamClientApplication: StreamClientApplication; diff --git a/src/subsonic/navidrome.ts b/src/subsonic/navidrome.ts new file mode 100644 index 0000000..7d96b4f --- /dev/null +++ b/src/subsonic/navidrome.ts @@ -0,0 +1,22 @@ +import { ArtistSummary, Sortable } from "../music_service"; +import { artistImageURN } from "./generic"; + +export type NDArtist = { + id: string; + name: string; + orderArtistName: string | undefined; + largeImageUrl: string | 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, + }), +}); + diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 94edbbf..c3634b7 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -7,12 +7,10 @@ import { taskEither as TE, task as T, either as E } from "fp-ts"; import { Subsonic, t, - DODGY_IMAGE_NAME, appendMimeTypeToClientFor, PingResponse, parseToken, asToken, - artistSummaryFromNDArtist, SubsonicCredentials, } from "../src/subsonic"; @@ -30,7 +28,6 @@ import { aTrack, } from "./builders"; import { asURLSearchParams } from "../src/utils"; -import { artistImageURN } from "../src/subsonic/generic"; describe("t", () => { it("should be an md5 of the password and the salt", () => { @@ -131,78 +128,6 @@ 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("Subsonic", () => { const url = "http://127.0.0.22:4567"; const username = `user1-${uuid()}`; diff --git a/tests/subsonic/navidrome.test.ts b/tests/subsonic/navidrome.test.ts new file mode 100644 index 0000000..f94d685 --- /dev/null +++ b/tests/subsonic/navidrome.test.ts @@ -0,0 +1,78 @@ +import { v4 as uuid } from "uuid"; +import { DODGY_IMAGE_NAME } from "../../src/subsonic"; +import { artistImageURN } from "../../src/subsonic/generic"; +import { artistSummaryFromNDArtist } from "../../src/subsonic/navidrome"; + + +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 }) + }); + }); + }); +}); + From d1ff224e89fd5646e199406bf0ce62db24972a45 Mon Sep 17 00:00:00 2001 From: simojenki Date: Sun, 6 Mar 2022 17:42:03 +1100 Subject: [PATCH 10/18] moving things around --- src/app.ts | 1 + src/subsonic/index.ts | 7 +- src/subsonic/{generic.ts => library.ts} | 100 +++++++++++------------- src/subsonic/navidrome.ts | 2 +- src/utils.ts | 2 +- tests/builders.ts | 2 +- tests/subsonic/generic.test.ts | 2 +- tests/subsonic/navidrome.test.ts | 2 +- 8 files changed, 57 insertions(+), 61 deletions(-) rename src/subsonic/{generic.ts => library.ts} (92%) diff --git a/src/app.ts b/src/app.ts index 162ecbd..b7b8304 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,6 +31,7 @@ const bonob = bonobService( const sonosSystem = sonos(config.sonos.discovery); +// todo: just pass in the customClientsForStringArray into subsonic and make it sort it out. const streamUserAgent = config.subsonic.customClientsFor ? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(",")) : DEFAULT; diff --git a/src/subsonic/index.ts b/src/subsonic/index.ts index 4954ab2..5ab2f45 100644 --- a/src/subsonic/index.ts +++ b/src/subsonic/index.ts @@ -15,7 +15,7 @@ import { import { b64Encode, b64Decode } from "../b64"; import { axiosImageFetcher, ImageFetcher } from "../images"; import { asURLSearchParams } from "../utils"; -import { NaivdromeMusicLibrary, SubsonicGenericMusicLibrary } from "./generic"; +import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./library"; export const t = (password: string, s: string) => Md5.hashStr(`${password}${s}`); @@ -194,10 +194,11 @@ export class Subsonic implements MusicService { private libraryFor = ( credentials: SubsonicCredentials ): Promise => { + const subsonicGenericLibrary = new SubsonicGenericMusicLibrary(this, credentials); if (credentials.type == "navidrome") { - return Promise.resolve(new NaivdromeMusicLibrary(this, credentials)); + return Promise.resolve(navidromeMusicLibrary(this.url, subsonicGenericLibrary, credentials)); } else { - return Promise.resolve(new SubsonicGenericMusicLibrary(this, credentials)); + return Promise.resolve(subsonicGenericLibrary); } }; } diff --git a/src/subsonic/generic.ts b/src/subsonic/library.ts similarity index 92% rename from src/subsonic/generic.ts rename to src/subsonic/library.ts index 2526ebd..03df6fd 100644 --- a/src/subsonic/generic.ts +++ b/src/subsonic/library.ts @@ -10,7 +10,7 @@ import { b64Decode, b64Encode } from "../b64"; import { assertSystem, BUrn } from "../burn"; import { Album, AlbumQuery, AlbumQueryType, AlbumSummary, Artist, ArtistQuery, ArtistSummary, AuthFailure, Credentials, Genre, IdName, Rating, Result, slice2, Sortable, Track } from "../music_service"; -import Subsonic, { DODGY_IMAGE_NAME, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, USER_AGENT } from "../subsonic"; +import Subsonic, { DODGY_IMAGE_NAME, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, USER_AGENT } from "."; import axios from "axios"; import { asURLSearchParams } from "../utils"; import { artistSummaryFromNDArtist, NDArtist } from "./navidrome"; @@ -725,65 +725,59 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { })); }; -export class NaivdromeMusicLibrary extends SubsonicGenericMusicLibrary { - - constructor(subsonic: Subsonic, credentials: SubsonicCredentials) { - super(subsonic, credentials); - } - - flavour = () => "navidrome"; - - bearerToken = (credentials: Credentials): TE.TaskEither => +export const navidromeMusicLibrary = (url: string, subsonicLibrary: SubsonicMusicLibrary, subsonicCredentials: SubsonicCredentials): SubsonicMusicLibrary => ({ + ...subsonicLibrary, + flavour: () => "navidrome", + bearerToken: (credentials: Credentials): TE.TaskEither => pipe( TE.tryCatch( () => axios.post( - `${this.subsonic.url}/auth/login`, + `${url}/auth/login`, _.pick(credentials, "username", "password") ), () => new AuthFailure("Failed to get bearerToken") ), 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, + ), + artists: async ( + q: ArtistQuery + ): Promise> => { + let params: any = { + _sort: "name", + _order: "ASC", + _start: q._index || "0", }; - } - - const x: Promise> = axios - .get(`${this.subsonic.url}/api/artist`, { - params: asURLSearchParams(params), - headers: { - "User-Agent": USER_AGENT, - "x-nd-authorization": `Bearer ${this.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"), - })); - - return x; - } -} \ No newline at end of file + if (q._count) { + params = { + ...params, + _end: (q._index || 0) + q._count, + }; + } + + const x: Promise> = axios + .get(`${url}/api/artist`, { + params: asURLSearchParams(params), + headers: { + "User-Agent": USER_AGENT, + "x-nd-authorization": `Bearer ${subsonicCredentials.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"), + })); + + return x; + } +}) \ No newline at end of file diff --git a/src/subsonic/navidrome.ts b/src/subsonic/navidrome.ts index 7d96b4f..6f4c75b 100644 --- a/src/subsonic/navidrome.ts +++ b/src/subsonic/navidrome.ts @@ -1,5 +1,5 @@ import { ArtistSummary, Sortable } from "../music_service"; -import { artistImageURN } from "./generic"; +import { artistImageURN } from "./library"; export type NDArtist = { id: string; diff --git a/src/utils.ts b/src/utils.ts index 4070e89..3069676 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -28,4 +28,4 @@ export function takeWithRepeats(things:T[], count: number) { result.push(things[i % things.length]) } return result; -} \ No newline at end of file +} diff --git a/tests/builders.ts b/tests/builders.ts index d3695de..37e2d6a 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -17,7 +17,7 @@ import { } from "../src/music_service"; import { b64Encode } from "../src/b64"; -import { artistImageURN } from "../src/subsonic/generic"; +import { artistImageURN } from "../src/subsonic/library"; const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max)); const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`; diff --git a/tests/subsonic/generic.test.ts b/tests/subsonic/generic.test.ts index 500b509..f86a365 100644 --- a/tests/subsonic/generic.test.ts +++ b/tests/subsonic/generic.test.ts @@ -12,7 +12,7 @@ jest.mock("randomstring"); import { aGenre, anAlbum, anArtist, aPlaylist, aPlaylistSummary, aSimilarArtist, aTrack, POP, ROCK } from "../builders"; import { BUrn } from "../../src/burn"; import { Album, AlbumQuery, AlbumSummary, albumToAlbumSummary, Artist, artistToArtistSummary, asArtistAlbumPairs, Playlist, PlaylistSummary, Rating, SimilarArtist, Track } from "../../src/music_service"; -import { artistImageURN, asGenre, asTrack, images, isValidImage, song, SubsonicGenericMusicLibrary } from "../../src/subsonic/generic"; +import { artistImageURN, asGenre, asTrack, images, isValidImage, song, SubsonicGenericMusicLibrary } from "../../src/subsonic/library"; import { EMPTY, error, FAILURE, subsonicOK, ok } from "../subsonic.test"; import Subsonic, { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; import { asURLSearchParams } from "../../src/utils"; diff --git a/tests/subsonic/navidrome.test.ts b/tests/subsonic/navidrome.test.ts index f94d685..6aca01c 100644 --- a/tests/subsonic/navidrome.test.ts +++ b/tests/subsonic/navidrome.test.ts @@ -1,6 +1,6 @@ import { v4 as uuid } from "uuid"; import { DODGY_IMAGE_NAME } from "../../src/subsonic"; -import { artistImageURN } from "../../src/subsonic/generic"; +import { artistImageURN } from "../../src/subsonic/library"; import { artistSummaryFromNDArtist } from "../../src/subsonic/navidrome"; From 2997e5ac3bc9e3d7821eefd7e31d20232cc3e661 Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 23 Apr 2022 14:11:50 +1000 Subject: [PATCH 11/18] prefer dev with z_ so goes to bottom of list --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cb8e8fb..96bbfe4 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,8 @@ "scripts": { "clean": "rm -Rf build node_modules", "build": "tsc", - "dev": "BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=bonobDev BNB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts", - "devr": "BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts", + "dev": "BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts", + "devr": "BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts", "register-dev": "ts-node ./src/register.ts http://$(hostname):4534", "test": "jest", "gitinfo": "git describe --tags > .gitinfo" From d2f13416f6d5f173c9121a36fcf628788c436e1d Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 23 Apr 2022 14:18:19 +1000 Subject: [PATCH 12/18] tests working --- src/http.ts | 89 ++ src/subsonic/http.ts | 113 ++ src/subsonic/index.ts | 90 +- src/subsonic/library.ts | 309 ++--- src/utils.ts | 3 +- tests/http.test.ts | 286 +++++ tests/subsonic.test.ts | 86 +- tests/subsonic/generic.test.ts | 1950 +++++++++++++++++++------------- 8 files changed, 1886 insertions(+), 1040 deletions(-) create mode 100644 src/http.ts create mode 100644 src/subsonic/http.ts create mode 100644 tests/http.test.ts diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..8801df9 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,89 @@ +import { AxiosPromise, AxiosRequestConfig, ResponseType } from "axios"; +import _ from "underscore"; + +export interface RequestModifier { + (config: AxiosRequestConfig): AxiosRequestConfig; +} + +export const no_op = (config: AxiosRequestConfig) => config; + +export interface Http { + (config: AxiosRequestConfig): AxiosPromise; +} + +// export const http = +// (base: Http = axios, modifier: RequestModifier = no_op): Http => +// (config: AxiosRequestConfig) => { +// console.log( +// `applying ${JSON.stringify(config)} onto ${JSON.stringify(modifier)}` +// ); +// const result = modifier(config); +// console.log(`result is ${JSON.stringify(result)}`); +// return base(result); +// }; + +// export const chain = +// (...modifiers: RequestModifier[]): RequestModifier => +// (config: AxiosRequestConfig) => +// modifiers.reduce( +// (config: AxiosRequestConfig, next: RequestModifier) => next(config), +// config +// ); + +// export const baseUrl = (baseURL: string) => (config: AxiosRequestConfig) => ({ +// ...config, +// baseURL, +// }); + +// export const axiosConfig = +// (additionalConfig: Partial) => +// (config: AxiosRequestConfig) => ({ ...config, ...additionalConfig }); + +// export const params = (params: any) => (config: AxiosRequestConfig) => { +// console.log( +// `params on config ${JSON.stringify( +// config.params +// )}, params applying ${JSON.stringify(params)}` +// ); +// const after = { ...config, params: { ...config.params, ...params } }; +// console.log(`params after ${JSON.stringify(after.params)}`); +// return after; +// }; + +// export const headers = (headers: any) => (config: AxiosRequestConfig) => ({ +// ...config, +// headers: { ...config.headers, ...headers }, +// }); +// export const formatJson = (): RequestModifier => (config: AxiosRequestConfig) => ({...config, params: { ...config.params, f: 'json' } }); +// export const subsonicAuth = (credentials: { username: string, password: string}) => (config: AxiosRequestConfig) => ({...config, params: { ...config.params, u: credentials.username, ...t_and_s(credentials.password) } }); + +export type RequestParams = { baseURL: string; url: string, params: any, headers: any, responseType: ResponseType } + +// todo: rename to http +export const http2 = + (base: Http, defaults: Partial): Http => + (config: AxiosRequestConfig) => { + let toApply = { + ...defaults, + ...config, + }; + if (defaults.params) { + toApply = { + ...toApply, + params: { + ...defaults.params, + ...config.params, + }, + }; + } + if (defaults.headers) { + toApply = { + ...toApply, + headers: { + ...defaults.headers, + ...config.headers, + }, + }; + } + return base(toApply); + }; diff --git a/src/subsonic/http.ts b/src/subsonic/http.ts new file mode 100644 index 0000000..47b1d7c --- /dev/null +++ b/src/subsonic/http.ts @@ -0,0 +1,113 @@ +import axios, { AxiosPromise, AxiosRequestConfig } from "axios"; +import { + DEFAULT_CLIENT_APPLICATION, + isError, + SubsonicEnvelope, + t_and_s, + USER_AGENT, +} from "."; +import { Http, http2 } from "../http"; +import { Credentials } from "../music_service"; +import { asURLSearchParams } from "../utils"; + +export const http = (base: string, credentials: Credentials): HTTP => ({ + get: async ( + path: string, + params: Partial<{ q: {}; config: AxiosRequestConfig | undefined }> + ) => + axios + .get(`${base}${path}`, { + params: asURLSearchParams({ + u: credentials.username, + v: "1.16.1", + c: DEFAULT_CLIENT_APPLICATION, + ...t_and_s(credentials.password), + f: "json", + ...(params.q || {}), + }), + headers: { + "User-Agent": USER_AGENT, + }, + ...(params.config || {}), + }) + .catch((e) => { + throw `Subsonic failed with: ${e}`; + }) + .then((response) => { + if (response.status != 200 && response.status != 206) { + throw `Subsonic failed with a ${response.status || "no!"} status`; + } else return response; + }), +}); + +export type HttpResponse = { + data: any; + status: number; + headers: any; +}; + +export interface HTTP { + get( + path: string, + params: Partial<{ q: {}; config: AxiosRequestConfig | undefined }> + ): Promise; +} + +// export const basic = (opts : AxiosRequestConfig) => axios(opts); + +// function whatDoesItLookLike() { +// const basic = axios; + +// const authenticatedClient = httpee(axios, chain( +// baseUrl("http://foobar"), +// subsonicAuth({username: 'bob', password: 'foo'}) +// )); +// const jsonClient = httpee(authenticatedClient, formatJson()) + +// jsonClient({ }) + +// } + +// .then((response) => response.data as SubsonicEnvelope) +// .then((json) => json["subsonic-response"]) +// .then((json) => { +// if (isError(json)) throw `Subsonic error:${json.error.message}`; +// else return json as unknown as T; +// }); + +export const raw = (response: AxiosPromise) => + response + .catch((e) => { + throw `Subsonic failed with: ${e}`; + }) + .then((response) => { + if (response.status != 200 && response.status != 206) { + throw `Subsonic failed with a ${response.status || "no!"} status`; + } else return response; + }); + + // todo: delete +export const getRaw2 = (http: Http) => + http({ method: "get" }) + .catch((e) => { + throw `Subsonic failed with: ${e}`; + }) + .then((response) => { + if (response.status != 200 && response.status != 206) { + throw `Subsonic failed with a ${response.status || "no!"} status`; + } else return response; + }); + +export const getJSON = async (http: Http): Promise => + getRaw2(http2(http, { params: { f: "json" } })).then(asJSON) as Promise; + +export const asJSON = (response: HttpResponse): T => { + const subsonicResponse = (response.data as SubsonicEnvelope)[ + "subsonic-response" + ]; + if (isError(subsonicResponse)) + throw `Subsonic error:${subsonicResponse.error.message}`; + else return subsonicResponse as unknown as T; +}; + +export default http; diff --git a/src/subsonic/index.ts b/src/subsonic/index.ts index 5ab2f45..3aee9f0 100644 --- a/src/subsonic/index.ts +++ b/src/subsonic/index.ts @@ -1,9 +1,10 @@ import { taskEither as TE } from "fp-ts"; import { pipe } from "fp-ts/lib/function"; import { Md5 } from "ts-md5/dist/md5"; -import axios, { AxiosRequestConfig } from "axios"; +import axios from "axios"; import randomstring from "randomstring"; import _ from "underscore"; +import { Http, http2 } from "../http"; import { Credentials, @@ -14,8 +15,8 @@ import { } from "../music_service"; import { b64Encode, b64Decode } from "../b64"; import { axiosImageFetcher, ImageFetcher } from "../images"; -import { asURLSearchParams } from "../utils"; import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./library"; +import { http, getJSON as getJSON2 } from "./http"; export const t = (password: string, s: string) => Md5.hashStr(`${password}${s}`); @@ -31,9 +32,7 @@ export const t_and_s = (password: string) => { // todo: this is an ND thing export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png"; - - -type SubsonicEnvelope = { +export type SubsonicEnvelope = { "subsonic-response": SubsonicResponse; }; @@ -61,9 +60,6 @@ export function isError( return (subsonicResponse as SubsonicError).error !== undefined; } - - - export type StreamClientApplication = (track: Track) => string; export const DEFAULT_CLIENT_APPLICATION = "bonob"; @@ -77,7 +73,6 @@ export function appendMimeTypeToClientFor(mimeTypes: string[]) { mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob"; } - export type SubsonicCredentials = Credentials & { type: string; bearer: string | undefined; @@ -85,7 +80,7 @@ export type SubsonicCredentials = Credentials & { export const asToken = (credentials: SubsonicCredentials) => b64Encode(JSON.stringify(credentials)); - + export const parseToken = (token: string): SubsonicCredentials => JSON.parse(b64Decode(token)); @@ -101,6 +96,7 @@ export class Subsonic implements MusicService { streamClientApplication: StreamClientApplication; // todo: why is this in here? externalImageFetcher: ImageFetcher; + base: Http; constructor( url: string, @@ -110,58 +106,34 @@ export class Subsonic implements MusicService { this.url = url; this.streamClientApplication = streamClientApplication; this.externalImageFetcher = externalImageFetcher; + this.base = http2(axios, { + baseURL: this.url, + params: { v: "1.16.1", c: DEFAULT_CLIENT_APPLICATION }, + headers: { "User-Agent": "bonob" }, + }); } - get = async ( - { username, password }: Credentials, - path: string, - q: {} = {}, - config: AxiosRequestConfig | undefined = {} - ) => - axios - .get(`${this.url}${path}`, { - params: asURLSearchParams({ - u: username, - v: "1.16.1", - c: DEFAULT_CLIENT_APPLICATION, - ...t_and_s(password), - ...q, - }), - headers: { - "User-Agent": USER_AGENT, - }, - ...config, - }) - .catch((e) => { - throw `Subsonic failed with: ${e}`; - }) - .then((response) => { - if (response.status != 200 && response.status != 206) { - throw `Subsonic failed with a ${response.status || "no!"} status`; - } else return response; - }); + // todo: delete + http = (credentials: Credentials) => http(this.url, credentials); + + authenticated = (credentials: Credentials, wrap: Http = this.base) => + http2(wrap, { + params: { + u: credentials.username, + ...t_and_s(credentials.password), + }, + }); getJSON = async ( - { username, password }: Credentials, - path: string, - q: {} = {} - ): Promise => - this.get({ username, password }, path, { f: "json", ...q }) - .then((response) => response.data as SubsonicEnvelope) - .then((json) => json["subsonic-response"]) - .then((json) => { - if (isError(json)) throw `Subsonic error:${json.error.message}`; - else return json as unknown as T; - }); + credentials: Credentials, + url: string, + params: {} = {} + ): Promise => getJSON2(http2(this.authenticated(credentials), { url, params })); generateToken = (credentials: Credentials) => pipe( TE.tryCatch( - () => - this.getJSON( - _.pick(credentials, "username", "password"), - "/rest/ping.view" - ), + () => getJSON2(http2(this.authenticated(credentials), { url: "/rest/ping.view" })), (e) => new AuthFailure(e as string) ), TE.chain(({ type }) => @@ -194,9 +166,15 @@ export class Subsonic implements MusicService { private libraryFor = ( credentials: SubsonicCredentials ): Promise => { - const subsonicGenericLibrary = new SubsonicGenericMusicLibrary(this, credentials); + const subsonicGenericLibrary = new SubsonicGenericMusicLibrary( + this, + credentials, + this.authenticated(credentials, this.base) + ); if (credentials.type == "navidrome") { - return Promise.resolve(navidromeMusicLibrary(this.url, subsonicGenericLibrary, credentials)); + return Promise.resolve( + navidromeMusicLibrary(this.url, subsonicGenericLibrary, credentials) + ); } else { return Promise.resolve(subsonicGenericLibrary); } diff --git a/src/subsonic/library.ts b/src/subsonic/library.ts index 03df6fd..95de93e 100644 --- a/src/subsonic/library.ts +++ b/src/subsonic/library.ts @@ -2,19 +2,43 @@ import { option as O, taskEither as TE } from "fp-ts"; import * as A from "fp-ts/Array"; import { pipe } from "fp-ts/lib/function"; import { ordString } from "fp-ts/lib/Ord"; -import { inject } from 'underscore'; +import { inject } from "underscore"; import _ from "underscore"; import logger from "../logger"; import { b64Decode, b64Encode } from "../b64"; -import { assertSystem, BUrn } from "../burn"; - -import { Album, AlbumQuery, AlbumQueryType, AlbumSummary, Artist, ArtistQuery, ArtistSummary, AuthFailure, Credentials, Genre, IdName, Rating, Result, slice2, Sortable, Track } from "../music_service"; -import Subsonic, { DODGY_IMAGE_NAME, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, USER_AGENT } from "."; +import { assertSystem, BUrn, format } from "../burn"; + +import { + Album, + AlbumQuery, + AlbumQueryType, + AlbumSummary, + Artist, + ArtistQuery, + ArtistSummary, + AuthFailure, + Credentials, + Genre, + IdName, + Rating, + Result, + slice2, + Sortable, + Track, +} from "../music_service"; +import Subsonic, { + DODGY_IMAGE_NAME, + SubsonicCredentials, + SubsonicMusicLibrary, + SubsonicResponse, + USER_AGENT, +} from "."; import axios from "axios"; import { asURLSearchParams } from "../utils"; import { artistSummaryFromNDArtist, NDArtist } from "./navidrome"; - +import { Http, http2 } from "../http"; +import { getRaw2 } from "./http"; type album = { id: string; @@ -60,7 +84,6 @@ type GetGenresResponse = SubsonicResponse & { }; }; - type GetArtistInfoResponse = SubsonicResponse & { artistInfo2: artistInfo; }; @@ -71,7 +94,6 @@ type GetArtistResponse = SubsonicResponse & { }; }; - export type images = { smallImageUrl: string | undefined; mediumImageUrl: string | undefined; @@ -85,7 +107,6 @@ type artistInfo = images & { similarArtist: artist[]; }; - export type song = { id: string; parent: string | undefined; @@ -143,7 +164,6 @@ type GetSongResponse = { song: song; }; - type Search3Response = SubsonicResponse & { searchResult3: { artist: artist[]; @@ -164,14 +184,12 @@ const AlbumQueryTypeToSubsonicType: Record = { starred: "highest", }; - export const isValidImage = (url: string | undefined) => url != undefined && !url.endsWith(DODGY_IMAGE_NAME); const artistIsInLibrary = (artistId: string | undefined) => artistId != undefined && artistId != "-1"; - const coverArtURN = (coverArt: string | undefined): BUrn | undefined => pipe( coverArt, @@ -180,8 +198,7 @@ const coverArtURN = (coverArt: string | undefined): BUrn | undefined => O.getOrElseW(() => undefined) ); - - // todo: is this the right place for this?? +// todo: is this the right place for this?? export const artistImageURN = ( spec: Partial<{ artistId: string | undefined; @@ -256,23 +273,27 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined => O.getOrElseW(() => undefined) ); - export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { subsonic: Subsonic; credentials: SubsonicCredentials; + http: Http; - constructor(subsonic: Subsonic, credentials: SubsonicCredentials) { + constructor( + subsonic: Subsonic, + credentials: SubsonicCredentials, + http: Http + ) { this.subsonic = subsonic; this.credentials = credentials; + this.http = http; } flavour = () => "subsonic"; - bearerToken = (_: Credentials): TE.TaskEither => TE.right(undefined); + bearerToken = (_: Credentials): TE.TaskEither => + TE.right(undefined); - artists = async ( - q: ArtistQuery - ): Promise> => + artists = async (q: ArtistQuery): Promise> => this.getArtists() .then(slice2(q)) .then(([page, total]) => ({ @@ -285,8 +306,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { })), })); - artist = async (id: string): Promise => - this.getArtistWithInfo(id); + artist = async (id: string): Promise => this.getArtistWithInfo(id); albums = async (q: AlbumQuery): Promise> => this.getAlbumList2(q); @@ -360,29 +380,28 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { trackId: string; range: string | undefined; }) => + // todo: all these headers and stuff can be rolled into httpeee this.getTrack(trackId).then((track) => - this.subsonic - .get( - this.credentials, - `/rest/stream`, - { - id: trackId, - c: this.subsonic.streamClientApplication(track), - }, - { + getRaw2( + http2(this.http, { + url: `/rest/stream`, + params: { + id: trackId, + c: this.subsonic.streamClientApplication(track), + }, headers: pipe( range, O.fromNullable, O.map((range) => ({ - "User-Agent": USER_AGENT, + // "User-Agent": USER_AGENT, Range: range, })), O.getOrElse(() => ({ - "User-Agent": USER_AGENT, + // "User-Agent": USER_AGENT, })) ), responseType: "stream", - } + }) ) .then((res) => ({ status: res.status, @@ -406,7 +425,9 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { data: Buffer.from(res.data, "binary"), })) .catch((e) => { - logger.error(`Failed getting coverArt for urn:'${coverArtURN}': ${e}`); + logger.error( + `Failed getting coverArt for '${format(coverArtURN)}': ${e}` + ); return undefined; }); @@ -429,21 +450,20 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { .catch(() => false); searchArtists = async (query: string) => - this.search3({ query, artistCount: 20 }).then( - ({ artists }) => - artists.map((artist) => ({ - id: artist.id, - name: artist.name, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: artist.artistImageUrl, - }), - })) + this.search3({ query, artistCount: 20 }).then(({ artists }) => + artists.map((artist) => ({ + id: artist.id, + name: artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), + })) ); searchAlbums = async (query: string) => - this.search3({ query, albumCount: 20 }).then( - ({ albums }) => this.toAlbumSummary(albums) + this.search3({ query, albumCount: 20 }).then(({ albums }) => + this.toAlbumSummary(albums) ); searchTracks = async (query: string) => @@ -530,9 +550,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { .then((songs) => Promise.all( songs.map((song) => - this.getAlbum(song.albumId!).then((album) => - asTrack(album, song) - ) + this.getAlbum(song.albumId!).then((album) => asTrack(album, song)) ) ) ); @@ -548,15 +566,15 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { .then((songs) => Promise.all( songs.map((song) => - this.getAlbum(song.albumId!).then((album) => - asTrack(album, song) - ) + this.getAlbum(song.albumId!).then((album) => asTrack(album, song)) ) ) ) ); - private getArtists = (): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> => + private getArtists = (): Promise< + (IdName & { albumCount: number; image: BUrn | undefined })[] + > => this.subsonic .getJSON(this.credentials, "/rest/getArtists") .then((it) => (it.artists.index || []).flatMap((it) => it.artist || [])) @@ -583,11 +601,15 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { }; }> => this.subsonic - .getJSON(this.credentials, "/rest/getArtistInfo2", { - id, - count: 50, - includeNotPresent: true, - }) + .getJSON( + this.credentials, + "/rest/getArtistInfo2", + { + id, + count: 50, + includeNotPresent: true, + } + ) .then((it) => it.artistInfo2) .then((it) => ({ images: { @@ -638,35 +660,30 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { })); private getArtistWithInfo = (id: string) => - Promise.all([ - this.getArtist(id), - this.getArtistInfo(id), - ]).then(([artist, artistInfo]) => ({ - id: artist.id, - name: artist.name, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: [ - artist.artistImageUrl, - artistInfo.images.l, - artistInfo.images.m, - artistInfo.images.s, - ].find(isValidImage), - }), - albums: artist.albums, - similarArtists: artistInfo.similarArtist, - })); + Promise.all([this.getArtist(id), this.getArtistInfo(id)]).then( + ([artist, artistInfo]) => ({ + id: artist.id, + name: artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: [ + artist.artistImageUrl, + artistInfo.images.l, + artistInfo.images.m, + artistInfo.images.s, + ].find(isValidImage), + }), + albums: artist.albums, + similarArtists: artistInfo.similarArtist, + }) + ); private getCoverArt = (credentials: Credentials, id: string, size?: number) => - this.subsonic.get( - credentials, - "/rest/getCoverArt", - size ? { id, size } : { id }, - { - headers: { "User-Agent": "bonob" }, - responseType: "arraybuffer", - } - ); + getRaw2(http2(this.subsonic.authenticated(credentials), { + url: "/rest/getCoverArt", + params: { id, size }, + responseType: "arraybuffer", + })); private getTrack = (id: string) => this.subsonic @@ -675,9 +692,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { }) .then((it) => it.song) .then((song) => - this.getAlbum(song.albumId!).then((album) => - asTrack(album, song) - ) + this.getAlbum(song.albumId!).then((album) => asTrack(album, song)) ); private toAlbumSummary = (albumList: album[]): AlbumSummary[] => @@ -711,73 +726,83 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { inject(it, (total, artist) => total + artist.albumCount, 0) ), this.subsonic - .getJSON(this.credentials, "/rest/getAlbumList2", { - type: AlbumQueryTypeToSubsonicType[q.type], - ...(q.genre ? { genre: b64Decode(q.genre) } : {}), - size: 500, - offset: q._index, - }) + .getJSON( + this.credentials, + "/rest/getAlbumList2", + { + type: AlbumQueryTypeToSubsonicType[q.type], + ...(q.genre ? { genre: b64Decode(q.genre) } : {}), + size: 500, + offset: q._index, + } + ) .then((response) => response.albumList2.album || []) .then(this.toAlbumSummary), ]).then(([total, albums]) => ({ results: albums.slice(0, q._count), total: albums.length == 500 ? total : (q._index || 0) + albums.length, })); -}; +} -export const navidromeMusicLibrary = (url: string, subsonicLibrary: SubsonicMusicLibrary, subsonicCredentials: SubsonicCredentials): SubsonicMusicLibrary => ({ +export const navidromeMusicLibrary = ( + url: string, + subsonicLibrary: SubsonicMusicLibrary, + subsonicCredentials: SubsonicCredentials +): SubsonicMusicLibrary => ({ ...subsonicLibrary, flavour: () => "navidrome", - bearerToken: (credentials: Credentials): TE.TaskEither => + bearerToken: ( + credentials: Credentials + ): TE.TaskEither => pipe( TE.tryCatch( () => - axios.post( - `${url}/auth/login`, - _.pick(credentials, "username", "password") - ), + axios({ + method: 'post', + baseURL: url, + url: `/auth/login`, + data: _.pick(credentials, "username", "password") + }), () => new AuthFailure("Failed to get bearerToken") ), 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", + 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, }; - if (q._count) { - params = { - ...params, - _end: (q._index || 0) + q._count, - }; - } - - const x: Promise> = axios - .get(`${url}/api/artist`, { - params: asURLSearchParams(params), - headers: { - "User-Agent": USER_AGENT, - "x-nd-authorization": `Bearer ${subsonicCredentials.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"), - })); - - return x; - } -}) \ No newline at end of file + } + + const x: Promise> = axios + .get(`${url}/api/artist`, { + params: asURLSearchParams(params), + headers: { + "User-Agent": USER_AGENT, + "x-nd-authorization": `Bearer ${subsonicCredentials.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"), + })); + + return x; + }, +}); diff --git a/src/utils.ts b/src/utils.ts index 3069676..3eba2c1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,6 @@ import { flatten } from "underscore"; +// todo: move this export const BROWSER_HEADERS = { accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", @@ -10,7 +11,7 @@ export const BROWSER_HEADERS = { "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0", }; - +// todo: move this export const asURLSearchParams = (q: any) => { const urlSearchParams = new URLSearchParams(); Object.keys(q).forEach((k) => { diff --git a/tests/http.test.ts b/tests/http.test.ts new file mode 100644 index 0000000..5b0f595 --- /dev/null +++ b/tests/http.test.ts @@ -0,0 +1,286 @@ +import { + + http2, +} from "../src/http"; + +// describe("request modifiers", () => { +// describe("baseUrl", () => { +// it.each([ +// [ +// { data: "bob" }, +// "http://example.com", +// { data: "bob", baseURL: "http://example.com" }, +// ], +// [ +// { baseURL: "http://originalBaseUrl.example.com" }, +// "http://example.com", +// { baseURL: "http://example.com" }, +// ], +// ])( +// "should apply the baseUrl", +// (requestConfig: any, value: string, expected: any) => { +// expect(baseUrl(value)(requestConfig)).toEqual(expected); +// } +// ); +// }); + +// describe("params", () => { +// it.each([ +// [ +// { data: "bob" }, +// { param1: "value1", param2: "value2" }, +// { data: "bob", params: { param1: "value1", param2: "value2" } }, +// ], +// [ +// { data: "bob", params: { orig1: "origValue1" } }, +// {}, +// { data: "bob", params: { orig1: "origValue1" } }, +// ], +// [ +// { data: "bob", params: { orig1: "origValue1" } }, +// { param1: "value1", param2: "value2" }, +// { +// data: "bob", +// params: { orig1: "origValue1", param1: "value1", param2: "value2" }, +// }, +// ], +// ])( +// "should apply the params", +// (requestConfig: any, newParams: any, expected: any) => { +// expect(params(newParams)(requestConfig)).toEqual(expected); +// } +// ); +// }); + +// describe("headers", () => { +// it.each([ +// [ +// { data: "bob" }, +// { h1: "value1", h2: "value2" }, +// { data: "bob", headers: { h1: "value1", h2: "value2" } }, +// ], +// [ +// { data: "bob", headers: { orig1: "origValue1" } }, +// {}, +// { data: "bob", headers: { orig1: "origValue1" } }, +// ], +// [ +// { data: "bob", headers: { orig1: "origValue1" } }, +// { h1: "value1", h2: "value2" }, +// { +// data: "bob", +// headers: { orig1: "origValue1", h1: "value1", h2: "value2" }, +// }, +// ], +// ])( +// "should apply the headers", +// (requestConfig: any, newParams: any, expected: any) => { +// expect(headers(newParams)(requestConfig)).toEqual(expected); +// } +// ); +// }); + +// describe("chain", () => { +// it.each([ +// [ +// { data: "bob" }, +// [params({ param1: "value1", param2: "value2" })], +// { data: "bob", params: { param1: "value1", param2: "value2" } }, +// ], +// [ +// { data: "bob" }, +// [params({ param1: "value1" }), params({ param2: "value2" })], +// { data: "bob", params: { param1: "value1", param2: "value2" } }, +// ], +// [{ data: "bob" }, [], { data: "bob" }], +// ])( +// "should apply the chain", +// (requestConfig: any, newParams: RequestModifier[], expected: any) => { +// expect(chain(...newParams)(requestConfig)).toEqual(expected); +// } +// ); +// }); + +// describe("wrapping", () => { +// const mockAxios = jest.fn(); + +// describe("baseURL", () => { +// const base = http( +// mockAxios, +// baseUrl("http://original.example.com") +// ); + +// describe("when no baseURL passed in when being invoked", () => { +// it("should use the original value", () => { +// base({}) +// expect(mockAxios).toHaveBeenCalledWith({ baseURL: "http://original.example.com" }); +// }); +// }); + +// describe("when a new baseURL is passed in when being invoked", () => { +// it("should use the new value", () => { +// base({ baseURL: "http://new.example.com" }) +// expect(mockAxios).toHaveBeenCalledWith({ baseURL: "http://new.example.com" }); +// }); +// }); +// }); + +// describe("params", () => { +// const base = http( +// mockAxios, +// params({ a: "1", b: "2" }) +// ); + +// it("should apply the modified when invoked", () => { +// base({ method: 'get' }); +// expect(mockAxios).toHaveBeenCalledWith({ method: 'get', params: { a: "1", b: "2" }}); +// }); + +// describe("wrapping the base", () => { +// const wrapped = http(base, params({ b: "2b", c: "3" })); + +// it("should the wrapped values as priority", () => { +// wrapped({ method: 'get', params: { a: "1b", c: "3b", d: "4" } }); +// expect(mockAxios).toHaveBeenCalledWith({ method: 'get', params: { a: "1b", b: "2b", c: "3b", d: "4" }}); +// }); +// }); +// }); +// }); +// }); + +describe("http2", () => { + const mockAxios = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + describe.each([ + ["baseURL"], + ["url"], + ])('%s', (field) => { + const getValue = (value: string) => { + const thing = {} as any; + thing[field] = value; + return thing; + }; + + const base = http2(mockAxios, getValue('base')); + + describe("using default", () => { + it("should use the default", () => { + base({}) + expect(mockAxios).toHaveBeenCalledWith(getValue('base')); + }); + }); + + describe("overriding", () => { + it("should use the override", () => { + base(getValue('override')) + expect(mockAxios).toHaveBeenCalledWith(getValue('override')); + }); + }); + + describe("wrapping", () => { + const firstLayer = http2(base, getValue('level1')); + const secondLayer = http2(firstLayer, getValue('level2')); + + describe("when the outter call provides a value", () => { + it("should apply it", () => { + secondLayer(getValue('outter')) + expect(mockAxios).toHaveBeenCalledWith(getValue('outter')); + }); + }); + + describe("when the outter call does not provide a value", () => { + it("should use the second layer", () => { + secondLayer({ }) + expect(mockAxios).toHaveBeenCalledWith(getValue('level2')); + }); + }); + }); + }); + + describe("requestType", () => { + const base = http2(mockAxios, { responseType: 'stream' }); + + describe("using default", () => { + it("should use the default", () => { + base({}) + expect(mockAxios).toHaveBeenCalledWith({ responseType: 'stream' }); + }); + }); + + describe("overriding", () => { + it("should use the override", () => { + base({ responseType: 'arraybuffer' }) + expect(mockAxios).toHaveBeenCalledWith({ responseType: 'arraybuffer' }); + }); + }); + + describe("wrapping", () => { + const firstLayer = http2(base, { responseType: 'arraybuffer' }); + const secondLayer = http2(firstLayer, { responseType: 'blob' }); + + describe("when the outter call provides a value", () => { + it("should apply it", () => { + secondLayer({ responseType: 'text' }) + expect(mockAxios).toHaveBeenCalledWith({ responseType: 'text' }); + }); + }); + + describe("when the outter call does not provide a value", () => { + it("should use the second layer", () => { + secondLayer({ }) + expect(mockAxios).toHaveBeenCalledWith({ responseType: 'blob' }); + }); + }); + }); + }); + + describe.each([ + ["params"], + ["headers"], + ])('%s', (field) => { + const getValues = (values: any) => { + const thing = {} as any; + thing[field] = values; + return thing; + } + const base = http2(mockAxios, getValues({ a: 1, b: 2, c: 3, d: 4 })); + + describe("using default", () => { + it("should use the default", () => { + base({}); + expect(mockAxios).toHaveBeenCalledWith(getValues({ a: 1, b: 2, c: 3, d: 4 })); + }); + }); + + describe("overriding", () => { + it("should use the override", () => { + base(getValues({ b: 22, e: 5 })); + expect(mockAxios).toHaveBeenCalledWith(getValues({ a: 1, b: 22, c: 3, d: 4, e: 5 })); + }); + }); + + describe("wrapping", () => { + const firstLayer = http2(base, getValues({ b: 22 })); + const secondLayer = http2(firstLayer, getValues({ c: 33 })); + + describe("when the outter call provides a value", () => { + it("should apply it", () => { + secondLayer(getValues({ a: 11, e: 5 })); + expect(mockAxios).toHaveBeenCalledWith(getValues({ a: 11, b: 22, c: 33, d: 4, e: 5 })); + }); + }); + + describe("when the outter call does not provide a value", () => { + it("should use the second layer", () => { + secondLayer({ }); + expect(mockAxios).toHaveBeenCalledWith(getValues({ a: 1, b: 22, c: 33, d: 4 })); + }); + }); + }); + }) +}); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index c3634b7..d0dcd76 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -17,7 +17,6 @@ import { import axios from "axios"; jest.mock("axios"); - import randomstring from "randomstring"; jest.mock("randomstring"); @@ -27,7 +26,6 @@ import { import { aTrack, } from "./builders"; -import { asURLSearchParams } from "../src/utils"; describe("t", () => { it("should be an md5 of the password and the salt", () => { @@ -129,7 +127,10 @@ const pingJson = (pingResponse: Partial = {}) => ({ const PING_OK = pingJson({ status: "ok" }); describe("Subsonic", () => { + const mockAxios = axios as unknown as jest.Mock; + const url = "http://127.0.0.22:4567"; + const baseURL = url; const username = `user1-${uuid()}`; const password = `pass1-${uuid()}`; const salt = "saltysalty"; @@ -141,16 +142,12 @@ describe("Subsonic", () => { ); const mockRandomstring = jest.fn(); - const mockGET = jest.fn(); - const mockPOST = jest.fn(); beforeEach(() => { jest.clearAllMocks(); jest.resetAllMocks(); randomstring.generate = mockRandomstring; - axios.get = mockGET; - axios.post = mockPOST; mockRandomstring.mockReturnValue(salt); }); @@ -187,7 +184,7 @@ describe("Subsonic", () => { describe("when the credentials are valid", () => { describe("when the backend is generic subsonic", () => { it("should be able to generate a token and then login using it", async () => { - (axios.get as jest.Mock).mockResolvedValue(ok(PING_OK)); + (mockAxios as jest.Mock).mockResolvedValue(ok(PING_OK)); const token = await tokenFor({ username, @@ -200,15 +197,18 @@ describe("Subsonic", () => { expect(parseToken(token.serviceToken)).toEqual({ username, password, type: PING_OK["subsonic-response"].type }) - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/ping.view`, + params: authParamsPlusJson, headers, }); }); it("should store the type of the subsonic server on the token", async () => { const type = "someSubsonicClone"; - (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type }))); + mockAxios.mockResolvedValue(ok(pingJson({ type }))); const token = await tokenFor({ username, @@ -221,8 +221,11 @@ describe("Subsonic", () => { expect(parseToken(token.serviceToken)).toEqual({ username, password, type }) - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/ping.view`, + params: authParamsPlusJson, headers, }); }); @@ -232,8 +235,9 @@ describe("Subsonic", () => { it("should login to nd and get the nd bearer token", async () => { const navidromeToken = `nd-${uuid()}`; - (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type: "navidrome" }))); - (axios.post as jest.Mock).mockResolvedValue(ok({ token: navidromeToken })); + mockAxios + .mockResolvedValueOnce(ok(pingJson({ type: "navidrome" }))) + .mockResolvedValueOnce(ok({ token: navidromeToken })); const token = await tokenFor({ username, @@ -246,13 +250,21 @@ describe("Subsonic", () => { expect(parseToken(token.serviceToken)).toEqual({ username, password, type: "navidrome", bearer: navidromeToken }) - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/ping.view`, + params: authParamsPlusJson, headers, }); - expect(axios.post).toHaveBeenCalledWith(`${url}/auth/login`, { - username, - password, + expect(mockAxios).toHaveBeenCalledWith({ + method: 'post', + baseURL, + url: `/auth/login`, + data: { + username, + password, + } }); }); }); @@ -260,7 +272,7 @@ describe("Subsonic", () => { describe("when the credentials are not valid", () => { it("should be able to generate a token and then login using it", async () => { - (axios.get as jest.Mock).mockResolvedValue({ + mockAxios.mockResolvedValue({ status: 200, data: error("40", "Wrong username or password"), }); @@ -276,7 +288,7 @@ describe("Subsonic", () => { describe("when the backend is generic subsonic", () => { it("should be able to generate a token and then login using it", async () => { const type = `subsonic-clone-${uuid()}`; - (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type }))); + mockAxios.mockResolvedValue(ok(pingJson({ type }))); const credentials = { username, password, type: "foo", bearer: undefined }; const originalToken = asToken(credentials) @@ -292,8 +304,11 @@ describe("Subsonic", () => { expect(parseToken(refreshedToken.serviceToken)).toEqual({ username, password, type }) - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method:'get', + baseURL, + url: `/rest/ping.view`, + params: authParamsPlusJson, headers, }); }); @@ -303,8 +318,9 @@ describe("Subsonic", () => { it("should login to nd and get the nd bearer token", async () => { const navidromeToken = `nd-${uuid()}`; - (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type: "navidrome" }))); - (axios.post as jest.Mock).mockResolvedValue(ok({ token: navidromeToken })); + mockAxios + .mockResolvedValueOnce(ok(pingJson({ type: "navidrome" }))) + .mockResolvedValueOnce(ok({ token: navidromeToken })); const credentials = { username, password, type: "navidrome", bearer: undefined }; const originalToken = asToken(credentials) @@ -320,13 +336,21 @@ describe("Subsonic", () => { expect(parseToken(refreshedToken.serviceToken)).toEqual({ username, password, type: "navidrome", bearer: navidromeToken }) - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/ping.view`, + params: authParamsPlusJson, headers, }); - expect(axios.post).toHaveBeenCalledWith(`${url}/auth/login`, { - username, - password, + expect(mockAxios).toHaveBeenCalledWith({ + method: 'post', + baseURL, + url: `/auth/login`, + data: { + username, + password, + } }); }); }); @@ -334,7 +358,7 @@ describe("Subsonic", () => { describe("when the credentials are not valid", () => { it("should be able to generate a token and then login using it", async () => { - (axios.get as jest.Mock).mockResolvedValue({ + mockAxios.mockResolvedValue({ status: 200, data: error("40", "Wrong username or password"), }); diff --git a/tests/subsonic/generic.test.ts b/tests/subsonic/generic.test.ts index f86a365..f99c6d5 100644 --- a/tests/subsonic/generic.test.ts +++ b/tests/subsonic/generic.test.ts @@ -1,30 +1,60 @@ -import { pipe } from "fp-ts/lib/function"; +import { pipe } from "fp-ts/lib/function"; import { option as O } from "fp-ts"; import { v4 as uuid } from "uuid"; -import axios from "axios"; +import axios, { AxiosRequestConfig } from "axios"; jest.mock("axios"); - import randomstring from "randomstring"; jest.mock("randomstring"); -import { aGenre, anAlbum, anArtist, aPlaylist, aPlaylistSummary, aSimilarArtist, aTrack, POP, ROCK } from "../builders"; +import { + aGenre, + anAlbum, + anArtist, + aPlaylist, + aPlaylistSummary, + aSimilarArtist, + aTrack, + POP, + ROCK, +} from "../builders"; import { BUrn } from "../../src/burn"; -import { Album, AlbumQuery, AlbumSummary, albumToAlbumSummary, Artist, artistToArtistSummary, asArtistAlbumPairs, Playlist, PlaylistSummary, Rating, SimilarArtist, Track } from "../../src/music_service"; -import { artistImageURN, asGenre, asTrack, images, isValidImage, song, SubsonicGenericMusicLibrary } from "../../src/subsonic/library"; +import { + Album, + AlbumQuery, + AlbumSummary, + albumToAlbumSummary, + Artist, + artistToArtistSummary, + asArtistAlbumPairs, + Playlist, + PlaylistSummary, + Rating, + SimilarArtist, + Track, +} from "../../src/music_service"; +import { + artistImageURN, + asGenre, + asTrack, + images, + isValidImage, + song, + SubsonicGenericMusicLibrary, +} from "../../src/subsonic/library"; import { EMPTY, error, FAILURE, subsonicOK, ok } from "../subsonic.test"; import Subsonic, { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; -import { asURLSearchParams } from "../../src/utils"; import { b64Encode } from "../../src/b64"; - - -const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => pipe( - coverArt, - O.fromNullable, - O.map(it => it.resource.split(":")[1]), - O.getOrElseW(() => "") -) +import { http2 } from "../../src/http"; + +const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => + pipe( + coverArt, + O.fromNullable, + O.map((it) => it.resource.split(":")[1]), + O.getOrElseW(() => "") + ); const asAlbumJson = ( artist: { id: string | undefined; name: string | undefined }, @@ -76,7 +106,6 @@ const asSongJson = (track: Track) => ({ year: "", }); - const asSimilarArtistJson = (similarArtist: SimilarArtist) => { if (similarArtist.inLibrary) return { @@ -114,7 +143,7 @@ const getAlbumListJson = (albums: [Artist, Album][]) => }, }); -type ArtistExtras = { artistImageUrl: string | undefined } +type ArtistExtras = { artistImageUrl: string | undefined }; const asArtistJson = ( artist: Artist, @@ -127,7 +156,10 @@ const asArtistJson = ( ...extras, }); -const getArtistJson = (artist: Artist, extras: ArtistExtras = { artistImageUrl: undefined }) => +const getArtistJson = ( + artist: Artist, + extras: ArtistExtras = { artistImageUrl: undefined } +) => subsonicOK({ artist: asArtistJson(artist, extras), }); @@ -150,7 +182,6 @@ const getAlbumJson = (artist: Artist, album: Album, tracks: Track[]) => const getSongJson = (track: Track) => subsonicOK({ song: asSongJson(track) }); - const getSimilarSongsJson = (tracks: Track[]) => subsonicOK({ similarSongs2: { song: tracks.map(asSongJson) } }); @@ -320,8 +351,14 @@ describe("artistImageURN", () => { describe("a valid external URL", () => { it("should return an external URN", () => { expect( - artistImageURN({ artistId: "someArtistId", artistImageURL: "http://example.com/image.jpg" }) - ).toEqual({ system: "external", resource: "http://example.com/image.jpg" }); + artistImageURN({ + artistId: "someArtistId", + artistImageURL: "http://example.com/image.jpg", + }) + ).toEqual({ + system: "external", + resource: "http://example.com/image.jpg", + }); }); }); @@ -331,7 +368,7 @@ describe("artistImageURN", () => { expect( artistImageURN({ artistId: "someArtistId", - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}`, }) ).toEqual({ system: "subsonic", resource: "art:someArtistId" }); }); @@ -342,7 +379,7 @@ describe("artistImageURN", () => { expect( artistImageURN({ artistId: "-1", - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}`, }) ).toBeUndefined(); }); @@ -353,7 +390,7 @@ describe("artistImageURN", () => { expect( artistImageURN({ artistId: undefined, - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}`, }) ).toBeUndefined(); }); @@ -363,19 +400,28 @@ describe("artistImageURN", () => { describe("undefined", () => { describe("and artistId is valid", () => { it("should return artist art by artist id URN", () => { - expect(artistImageURN({ artistId: "someArtistId", artistImageURL: undefined })).toEqual({system:"subsonic", resource:"art:someArtistId"}); + expect( + artistImageURN({ + artistId: "someArtistId", + artistImageURL: undefined, + }) + ).toEqual({ system: "subsonic", resource: "art:someArtistId" }); }); }); describe("and artistId is -1", () => { it("should return error icon", () => { - expect(artistImageURN({ artistId: "-1", artistImageURL: undefined })).toBeUndefined(); + expect( + artistImageURN({ artistId: "-1", artistImageURL: undefined }) + ).toBeUndefined(); }); }); describe("and artistId is undefined", () => { it("should return error icon", () => { - expect(artistImageURN({ artistId: undefined, artistImageURL: undefined })).toBeUndefined(); + expect( + artistImageURN({ artistId: undefined, artistImageURL: undefined }) + ).toBeUndefined(); }); }); }); @@ -385,7 +431,13 @@ describe("artistImageURN", () => { describe("asTrack", () => { describe("when the song has no artistId", () => { const album = anAlbum(); - const track = aTrack({ artist: { id: undefined, name: "Not in library so no id", image: undefined }}); + const track = aTrack({ + artist: { + id: undefined, + name: "Not in library so no id", + image: undefined, + }, + }); it("should provide no artistId", () => { const result = asTrack(album, { ...asSongJson(track) }); @@ -399,7 +451,7 @@ describe("asTrack", () => { const album = anAlbum(); it("should provide a ? to sonos", () => { - const result = asTrack(album, { id: '1' } as any as song); + const result = asTrack(album, { id: "1" } as any as song); expect(result.artist.id).toBeUndefined(); expect(result.artist.name).toEqual("?"); expect(result.artist.image).toBeUndefined(); @@ -427,61 +479,81 @@ describe("asTrack", () => { }); describe("SubsonicGenericMusicLibrary", () => { + const mockAxios = axios as unknown as jest.Mock; const mockRandomstring = jest.fn(); - const mockGET = jest.fn(); const mockPOST = jest.fn(); - + const url = "http://127.0.0.22:4567"; + const baseURL = url; const username = `user1-${uuid()}`; const password = `pass1-${uuid()}`; const salt = "saltysalty"; const streamClientApplication = jest.fn(); const subsonic = new Subsonic(url, streamClientApplication) + + const authParams = { + u: username, + v: "1.16.1", + c: "bonob", + t: t(password, salt), + s: salt, + }; + + const authParamsPlusJson = { + ...authParams, + f: "json", + }; + + const headers = { + "User-Agent": "bonob", + }; + const generic = new SubsonicGenericMusicLibrary( subsonic, { - username, + username, password, type: 'subsonic', bearer: undefined - } + }, + // todo: all this stuff doesnt need to be defaulted in here. + http2(mockAxios, { + baseURL, + params: authParams, + headers + }) ); - beforeEach(() => { jest.clearAllMocks(); jest.resetAllMocks(); randomstring.generate = mockRandomstring; - axios.get = mockGET; axios.post = mockPOST; mockRandomstring.mockReturnValue(salt); }); - const authParams = { - u: username, - v: "1.16.1", - c: "bonob", - t: t(password, salt), - s: salt, - }; - - const authParamsPlusJson = { - ...authParams, - f: "json", - }; - const headers = { - "User-Agent": "bonob", - }; + const streamRequest = (opts: Pick) => ({ + baseURL, + method: "get", + url: opts.url, + params: { + ...authParams, + ...opts.params, + }, + headers, + responseType: "arraybuffer", + }); describe("getting genres", () => { describe("when there are none", () => { beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(getGenresJson([])))); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson([]))) + ); }); it("should return empty array", async () => { @@ -489,8 +561,11 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual([]); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getGenres`, + params: authParamsPlusJson, headers, }); }); @@ -503,10 +578,9 @@ describe("SubsonicGenericMusicLibrary", () => { ]; beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getGenresJson(genres))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson(genres))) + ); }); it("should return them alphabetically sorted", async () => { @@ -514,8 +588,11 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getGenres`, + params: authParamsPlusJson, headers, }); }); @@ -531,10 +608,9 @@ describe("SubsonicGenericMusicLibrary", () => { ]; beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getGenresJson(genres))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson(genres))) + ); }); it("should return them alphabetically sorted", async () => { @@ -547,8 +623,11 @@ describe("SubsonicGenericMusicLibrary", () => { { id: b64Encode("g4"), name: "g4" }, ]); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getGenres`, + params: authParamsPlusJson, headers, }); }); @@ -581,7 +660,7 @@ describe("SubsonicGenericMusicLibrary", () => { }); beforeEach(() => { - mockGET + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getArtistJson(artist))) ) @@ -596,26 +675,32 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual({ id: `${artist.id}`, name: artist.name, - image: { system:"subsonic", resource:`art:${artist.id}` }, + image: { system: "subsonic", resource: `art:${artist.id}` }, albums: artist.albums, similarArtists: artist.similarArtists, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { ...authParamsPlusJson, id: artist.id, - }), + }, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, - }), + }, headers, }); }); @@ -638,7 +723,7 @@ describe("SubsonicGenericMusicLibrary", () => { }); beforeEach(() => { - mockGET + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getArtistJson(artist))) ) @@ -653,26 +738,32 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual({ id: artist.id, name: artist.name, - image: { system:"subsonic", resource:`art:${artist.id}` }, + image: { system: "subsonic", resource: `art:${artist.id}` }, albums: artist.albums, similarArtists: artist.similarArtists, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { ...authParamsPlusJson, id: artist.id, - }), + }, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, - }), + }, headers, }); }); @@ -689,7 +780,7 @@ describe("SubsonicGenericMusicLibrary", () => { }); beforeEach(() => { - mockGET + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getArtistJson(artist))) ) @@ -704,26 +795,32 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual({ id: artist.id, name: artist.name, - image: { system:"subsonic", resource: `art:${artist.id}` }, + image: { system: "subsonic", resource: `art:${artist.id}` }, albums: artist.albums, similarArtists: artist.similarArtists, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { ...authParamsPlusJson, id: artist.id, - }), + }, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, - }), + }, headers, }); }); @@ -738,12 +835,22 @@ describe("SubsonicGenericMusicLibrary", () => { const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; beforeEach(() => { - mockGET + mockAxios .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) + Promise.resolve( + ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl })) + ) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl}))) + Promise.resolve( + ok( + getArtistInfoJson(artist, { + smallImageUrl: dodgyImageUrl, + mediumImageUrl: dodgyImageUrl, + largeImageUrl: dodgyImageUrl, + }) + ) + ) ); }); @@ -761,21 +868,27 @@ describe("SubsonicGenericMusicLibrary", () => { similarArtists: [], }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { ...authParamsPlusJson, id: artist.id, - }), + }, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, - }), + }, headers, }); }); @@ -790,12 +903,27 @@ describe("SubsonicGenericMusicLibrary", () => { const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; beforeEach(() => { - mockGET + mockAxios .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: 'http://example.com:1234/good/looking/image.png' }))) + Promise.resolve( + ok( + getArtistJson(artist, { + artistImageUrl: + "http://example.com:1234/good/looking/image.png", + }) + ) + ) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl }))) + Promise.resolve( + ok( + getArtistInfoJson(artist, { + smallImageUrl: dodgyImageUrl, + mediumImageUrl: dodgyImageUrl, + largeImageUrl: dodgyImageUrl, + }) + ) + ) ); }); @@ -805,30 +933,39 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual({ id: artist.id, name: artist.name, - image: { system: "external", resource: 'http://example.com:1234/good/looking/image.png' }, + image: { + system: "external", + resource: "http://example.com:1234/good/looking/image.png", + }, albums: artist.albums, similarArtists: [], }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { ...authParamsPlusJson, id: artist.id, - }), + }, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, - }), + }, headers, }); }); - }); + }); describe("and has a good large external image uri from getArtistInfo route", () => { const artist: Artist = anArtist({ @@ -839,12 +976,23 @@ describe("SubsonicGenericMusicLibrary", () => { const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; beforeEach(() => { - mockGET + mockAxios .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) + Promise.resolve( + ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl })) + ) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: 'http://example.com:1234/good/large/image.png' }))) + Promise.resolve( + ok( + getArtistInfoJson(artist, { + smallImageUrl: dodgyImageUrl, + mediumImageUrl: dodgyImageUrl, + largeImageUrl: + "http://example.com:1234/good/large/image.png", + }) + ) + ) ); }); @@ -854,31 +1002,39 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual({ id: artist.id, name: artist.name, - image: { system: "external", resource: 'http://example.com:1234/good/large/image.png' }, + image: { + system: "external", + resource: "http://example.com:1234/good/large/image.png", + }, albums: artist.albums, similarArtists: [], }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { ...authParamsPlusJson, id: artist.id, - }), + }, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, - }), + }, headers, }); }); - }); - + }); describe("and has a good medium external image uri from getArtistInfo route", () => { const artist: Artist = anArtist({ @@ -889,12 +1045,23 @@ describe("SubsonicGenericMusicLibrary", () => { const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; beforeEach(() => { - mockGET + mockAxios .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) + Promise.resolve( + ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl })) + ) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: 'http://example.com:1234/good/medium/image.png', largeImageUrl: dodgyImageUrl }))) + Promise.resolve( + ok( + getArtistInfoJson(artist, { + smallImageUrl: dodgyImageUrl, + mediumImageUrl: + "http://example.com:1234/good/medium/image.png", + largeImageUrl: dodgyImageUrl, + }) + ) + ) ); }); @@ -904,30 +1071,39 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual({ id: artist.id, name: artist.name, - image: { system:"external", resource: 'http://example.com:1234/good/medium/image.png' }, + image: { + system: "external", + resource: "http://example.com:1234/good/medium/image.png", + }, albums: artist.albums, similarArtists: [], }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { ...authParamsPlusJson, id: artist.id, - }), + }, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, - }), + }, headers, }); }); - }); + }); describe("and has multiple albums", () => { const album1: Album = anAlbum({ genre: asGenre("Pop") }); @@ -940,7 +1116,7 @@ describe("SubsonicGenericMusicLibrary", () => { }); beforeEach(() => { - mockGET + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getArtistJson(artist))) ) @@ -960,21 +1136,27 @@ describe("SubsonicGenericMusicLibrary", () => { similarArtists: [], }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { ...authParamsPlusJson, id: artist.id, - }), + }, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, - }), + }, headers, }); }); @@ -989,7 +1171,7 @@ describe("SubsonicGenericMusicLibrary", () => { }); beforeEach(() => { - mockGET + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getArtistJson(artist))) ) @@ -1009,21 +1191,27 @@ describe("SubsonicGenericMusicLibrary", () => { similarArtists: [], }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { ...authParamsPlusJson, id: artist.id, - }), + }, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, - }), + }, headers, }); }); @@ -1036,7 +1224,7 @@ describe("SubsonicGenericMusicLibrary", () => { }); beforeEach(() => { - mockGET + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getArtistJson(artist))) ) @@ -1056,21 +1244,27 @@ describe("SubsonicGenericMusicLibrary", () => { similarArtists: [], }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { ...authParamsPlusJson, id: artist.id, - }), + }, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, - }), + }, headers, }); }); @@ -1082,69 +1276,67 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when subsonic flavour is generic", () => { describe("when there are indexes, but no artists", () => { beforeEach(() => { - mockGET - - .mockImplementationOnce(() => - Promise.resolve( - ok( - subsonicOK({ - artists: { - index: [ - { - name: "#", - }, - { - name: "A", - }, - { - name: "B", - }, - ], - }, - }) - ) + mockAxios.mockImplementationOnce(() => + Promise.resolve( + ok( + subsonicOK({ + artists: { + index: [ + { + name: "#", + }, + { + name: "A", + }, + { + name: "B", + }, + ], + }, + }) ) - ); + ) + ); }); - + it("should return empty", async () => { const artists = await generic.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( - subsonicOK({ - artists: {}, - }) - ) + mockAxios.mockImplementationOnce(() => + Promise.resolve( + ok( + subsonicOK({ + artists: {}, + }) ) - ); + ) + ); }); - + it("should return empty", async () => { const artists = await generic.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 artist1 = anArtist({ + albums: [anAlbum(), anAlbum(), anAlbum(), anAlbum()], + }); + const asArtistsJson = subsonicOK({ artists: { index: [ @@ -1161,56 +1353,59 @@ describe("SubsonicGenericMusicLibrary", () => { ], }, }); - + describe("when it all fits on one page", () => { beforeEach(() => { - mockGET - - .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson))); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson)) + ); }); - + it("should return the single artist", async () => { const artists = await generic.artists({ _index: 0, _count: 100 }); - - const expectedResults = [{ - id: artist1.id, - image: artist1.image, - name: artist1.name, - sortName: artist1.name - }]; - + + 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), + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); }); }); }); - + describe("when there are artists", () => { - const artist1 = anArtist({ name: "A Artist", albums:[anAlbum()] }); + 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(asArtistsJson(artists))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ); }); - + it("should return all the artists", async () => { const artists = await generic.artists({ _index: 0, _count: 100 }); - + const expectedResults = [artist1, artist2, artist3, artist4].map( (it) => ({ id: it.id, @@ -1219,49 +1414,53 @@ describe("SubsonicGenericMusicLibrary", () => { sortName: it.name, }) ); - + expect(artists).toEqual({ results: expectedResults, total: 4, }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); }); }); - + describe("when paging specified", () => { beforeEach(() => { - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ); }); - + it("should return only the correct page of artists", async () => { const artists = await generic.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), + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); }); }); }); }); - + // todo: put this test back // describe("when the subsonic type is navidrome", () => { // const ndArtist1 = { @@ -1296,7 +1495,7 @@ describe("SubsonicGenericMusicLibrary", () => { // .mockImplementationOnce(() => Promise.resolve(ok(pingJson({ type: "navidrome" })))) // .mockImplementationOnce(() => // Promise.resolve({ - // status: 200, + // status: 200, // data: [ // ndArtist1, // ndArtist2, @@ -1311,11 +1510,11 @@ describe("SubsonicGenericMusicLibrary", () => { // (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: [ // artistSummaryFromNDArtist(ndArtist1), @@ -1346,7 +1545,7 @@ describe("SubsonicGenericMusicLibrary", () => { // .mockImplementationOnce(() => Promise.resolve(ok(pingJson({ type: "navidrome" })))) // .mockImplementationOnce(() => // Promise.resolve({ - // status: 200, + // status: 200, // data: [ // ndArtist3, // ndArtist4, @@ -1359,11 +1558,11 @@ describe("SubsonicGenericMusicLibrary", () => { // (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), @@ -1392,7 +1591,7 @@ describe("SubsonicGenericMusicLibrary", () => { // .mockImplementationOnce(() => Promise.resolve(ok(pingJson({ type: "navidrome" })))) // .mockImplementationOnce(() => // Promise.resolve({ - // status: 200, + // status: 200, // data: [ // ndArtist3, // ndArtist4, @@ -1405,11 +1604,11 @@ describe("SubsonicGenericMusicLibrary", () => { // (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), @@ -1450,8 +1649,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("by genre", () => { beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson([artist]))) ) @@ -1482,19 +1680,25 @@ describe("SubsonicGenericMusicLibrary", () => { total: 2, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "byGenre", genre: "Pop", size: 500, offset: 0, - }), + }, headers, }); }); @@ -1502,8 +1706,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("by newest", () => { beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson([artist]))) ) @@ -1533,18 +1736,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 3, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "newest", size: 500, offset: 0, - }), + }, headers, }); }); @@ -1552,8 +1761,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("by recently played", () => { beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson([artist]))) ) @@ -1583,18 +1791,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 2, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "recent", size: 500, offset: 0, - }), + }, headers, }); }); @@ -1602,8 +1816,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("by frequently played", () => { beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson([artist]))) ) @@ -1624,18 +1837,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 1, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "frequent", size: 500, offset: 0, - }), + }, headers, }); }); @@ -1643,8 +1862,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("by starred", () => { beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson([artist]))) ) @@ -1665,18 +1883,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 1, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "highest", size: 500, offset: 0, - }), + }, headers, }); }); @@ -1692,8 +1916,7 @@ describe("SubsonicGenericMusicLibrary", () => { const albums = artists.flatMap((artist) => artist.albums); beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson(artists))) ) @@ -1715,18 +1938,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 1, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: 0, - }), + }, headers, }); }); @@ -1741,8 +1970,7 @@ describe("SubsonicGenericMusicLibrary", () => { const albums = artists.flatMap((artist) => artist.albums); beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson(artists))) ) @@ -1764,18 +1992,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 0, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: 0, - }), + }, headers, }); }); @@ -1807,8 +2041,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("querying for all of them", () => { it("should return all of them with corrent paging information", async () => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson(artists))) ) @@ -1828,18 +2061,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 6, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: 0, - }), + }, headers, }); }); @@ -1847,8 +2086,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("querying for a page of them", () => { it("should return the page with the corrent paging information", async () => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson(artists))) ) @@ -1878,18 +2116,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 6, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: 2, - }), + }, headers, }); }); @@ -1917,8 +2161,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when the number of albums returned from getAlbums is less the number of albums in the getArtists endpoint", () => { describe("when the query comes back on 1 page", () => { beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson(artists))) ) @@ -1950,20 +2193,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 4, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: q._index, - }), + }, headers, } ); @@ -1972,8 +2219,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when the query is for the first page", () => { beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson(artists))) ) @@ -2006,20 +2252,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 4, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: q._index, - }), + }, headers, } ); @@ -2028,8 +2278,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when the query is for the last page only", () => { beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson(artists))) ) @@ -2061,20 +2310,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 4, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: q._index, - }), + }, headers, } ); @@ -2085,8 +2338,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when the number of albums returned from getAlbums is more than the number of albums in the getArtists endpoint", () => { describe("when the query comes back on 1 page", () => { beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve( ok( @@ -2126,20 +2378,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 5, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: q._index, - }), + }, headers, } ); @@ -2148,8 +2404,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when the query is for the first page", () => { beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve( ok( @@ -2189,20 +2444,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 5, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: q._index, - }), + }, headers, } ); @@ -2211,8 +2470,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when the query is for the last page only", () => { beforeEach(() => { - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve( ok( @@ -2250,20 +2508,24 @@ describe("SubsonicGenericMusicLibrary", () => { total: 5, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, headers, }); - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: q._index, - }), + }, headers, } ); @@ -2289,11 +2551,9 @@ describe("SubsonicGenericMusicLibrary", () => { ]; beforeEach(() => { - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); }); it("should return the album", async () => { @@ -2301,11 +2561,14 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual(album); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbum`, + params: { ...authParamsPlusJson, id: album.id, - }), + }, headers, }); }); @@ -2366,11 +2629,9 @@ describe("SubsonicGenericMusicLibrary", () => { const tracks = [track1, track2, track3, track4]; beforeEach(() => { - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); }); it("should return the album", async () => { @@ -2378,11 +2639,14 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual([track1, track2, track3, track4]); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbum`, + params: { ...authParamsPlusJson, id: album.id, - }), + }, headers, }); }); @@ -2412,11 +2676,9 @@ describe("SubsonicGenericMusicLibrary", () => { const tracks = [track]; beforeEach(() => { - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); }); it("should return the album", async () => { @@ -2424,11 +2686,14 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual([track]); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbum`, + params: { ...authParamsPlusJson, id: album.id, - }), + }, headers, }); }); @@ -2446,11 +2711,9 @@ describe("SubsonicGenericMusicLibrary", () => { const tracks: Track[] = []; beforeEach(() => { - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); }); it("should empty array", async () => { @@ -2458,11 +2721,14 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual([]); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: '/rest/getAlbum', + params: { ...authParamsPlusJson, id: album.id, - }), + }, headers, }); }); @@ -2492,8 +2758,7 @@ describe("SubsonicGenericMusicLibrary", () => { }, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -2508,19 +2773,25 @@ describe("SubsonicGenericMusicLibrary", () => { rating: { love: true, stars: 4 }, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getSong`, + params: { ...authParamsPlusJson, id: track.id, - }), + }, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbum`, + params: { ...authParamsPlusJson, id: album.id, - }), + }, headers, }); }); @@ -2538,8 +2809,7 @@ describe("SubsonicGenericMusicLibrary", () => { }, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -2554,19 +2824,25 @@ describe("SubsonicGenericMusicLibrary", () => { rating: { love: false, stars: 0 }, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getSong`, + params: { ...authParamsPlusJson, id: track.id, - }), + }, headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbum`, + params: { ...authParamsPlusJson, id: album.id, - }), + }, headers, }); }); @@ -2580,7 +2856,7 @@ describe("SubsonicGenericMusicLibrary", () => { const album = anAlbum({ genre }); const artist = anArtist({ - albums: [album] + albums: [album], }); const track = aTrack({ id: trackId, @@ -2608,9 +2884,7 @@ describe("SubsonicGenericMusicLibrary", () => { data: stream, }; - mockGET - - .mockImplementationOnce(() => + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => @@ -2646,9 +2920,7 @@ describe("SubsonicGenericMusicLibrary", () => { data: stream, }; - mockGET - - .mockImplementationOnce(() => + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => @@ -2686,9 +2958,7 @@ describe("SubsonicGenericMusicLibrary", () => { data: stream, }; - mockGET - - .mockImplementationOnce(() => + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => @@ -2706,11 +2976,14 @@ describe("SubsonicGenericMusicLibrary", () => { }); expect(result.stream).toEqual(stream); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/stream`, + params: { ...authParams, id: trackId, - }), + }, headers: { "User-Agent": "bonob", }, @@ -2719,6 +2992,7 @@ describe("SubsonicGenericMusicLibrary", () => { }); }); + // todo: should not be the string navidrome in the generic driver describe("navidrome returns something other than a 200", () => { it("should fail", async () => { const trackId = "track123"; @@ -2726,14 +3000,12 @@ describe("SubsonicGenericMusicLibrary", () => { const streamResponse = { status: 400, headers: { - 'content-type': 'text/html', - 'content-length': '33' - } + "content-type": "text/html", + "content-length": "33", + }, }; - mockGET - - .mockImplementationOnce(() => + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => @@ -2751,9 +3023,7 @@ describe("SubsonicGenericMusicLibrary", () => { it("should fail", async () => { const trackId = "track123"; - mockGET - - .mockImplementationOnce(() => + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => @@ -2787,15 +3057,16 @@ describe("SubsonicGenericMusicLibrary", () => { data: stream, }; - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => Promise.resolve(ok(getAlbumJson(artist, album, []))) ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); + .mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); const result = await generic.stream({ trackId, range }); @@ -2807,14 +3078,17 @@ describe("SubsonicGenericMusicLibrary", () => { }); expect(result.stream).toEqual(stream); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: "get", + baseURL, + url: `/rest/stream`, + params: { ...authParams, - id: trackId, - }), + id: trackId + }, headers: { "User-Agent": "bonob", - Range: range, + Range: range }, responseType: "stream", }); @@ -2836,30 +3110,33 @@ describe("SubsonicGenericMusicLibrary", () => { data: Buffer.from("the track", "ascii"), }; - mockGET - - .mockImplementationOnce(() => + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => Promise.resolve(ok(getAlbumJson(artist, album, [track]))) ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); + .mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); await generic.stream({ trackId, range: undefined }); expect(streamClientApplication).toHaveBeenCalledWith(track); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: asURLSearchParams({ - ...authParams, - id: trackId, - c: clientApplication, - }), - headers: { - "User-Agent": "bonob", - }, - responseType: "stream", - }); + expect(mockAxios).toHaveBeenCalledWith({ + method: "get", + baseURL, + url: `/rest/stream`, + params: { + ...authParams, + id: trackId, + c: clientApplication + }, + headers: { + "User-Agent": "bonob", + }, + responseType: "stream", + }); }); }); @@ -2877,25 +3154,29 @@ describe("SubsonicGenericMusicLibrary", () => { data: Buffer.from("the track", "ascii"), }; - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => Promise.resolve(ok(getAlbumJson(artist, album, [track]))) ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); + .mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); await generic.stream({ trackId, range }); expect(streamClientApplication).toHaveBeenCalledWith(track); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/stream`, + params: { ...authParams, id: trackId, c: clientApplication, - }), + }, headers: { "User-Agent": "bonob", Range: range, @@ -2919,11 +3200,14 @@ describe("SubsonicGenericMusicLibrary", () => { data: Buffer.from("the image", "ascii"), }; const coverArtId = "someCoverArt"; - const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; + const coverArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; - mockGET - - .mockImplementationOnce(() => Promise.resolve(streamResponse)); + mockAxios.mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); const result = await generic.coverArt(coverArtURN); @@ -2932,14 +3216,16 @@ describe("SubsonicGenericMusicLibrary", () => { data: streamResponse.data, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - }), - headers, - responseType: "arraybuffer", - }); + expect(mockAxios).toHaveBeenCalledWith( + streamRequest( + streamRequest({ + url: `/rest/getCoverArt`, + params: { + id: coverArtId, + }, + }) + ) + ); }); }); @@ -2953,12 +3239,15 @@ describe("SubsonicGenericMusicLibrary", () => { data: Buffer.from("the image", "ascii"), }; const coverArtId = uuid(); - const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` } + const coverArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; const size = 1879; - mockGET - - .mockImplementationOnce(() => Promise.resolve(streamResponse)); + mockAxios.mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); const result = await generic.coverArt(coverArtURN, size); @@ -2967,15 +3256,15 @@ describe("SubsonicGenericMusicLibrary", () => { data: streamResponse.data, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - size, - }), - headers, - responseType: "arraybuffer", - }); + expect(mockAxios).toHaveBeenLastCalledWith( + streamRequest({ + url: `/rest/getCoverArt`, + params: { + id: coverArtId, + size, + }, + }) + ); }); }); @@ -2983,11 +3272,12 @@ describe("SubsonicGenericMusicLibrary", () => { it("should return undefined", async () => { const size = 1879; - mockGET - - .mockImplementationOnce(() => Promise.reject("BOOOM")); + mockAxios.mockImplementationOnce(() => Promise.reject("BOOOM")); - const result = await generic.coverArt({ system: "external", resource: "http://localhost:404" }, size); + const result = await generic.coverArt( + { system: "external", resource: "http://localhost:404" }, + size + ); expect(result).toBeUndefined(); }); @@ -2997,10 +3287,10 @@ describe("SubsonicGenericMusicLibrary", () => { describe("fetching cover art", () => { describe("when urn.resource is not subsonic", () => { it("should be undefined", async () => { - const covertArtURN = { system: "notSubsonic", resource: `art:${uuid()}` }; - - mockGET - ; + const covertArtURN = { + system: "notSubsonic", + resource: `art:${uuid()}`, + }; const result = await generic.coverArt(covertArtURN, 190); @@ -3010,8 +3300,11 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when no size is specified", () => { it("should fetch the image", async () => { - const coverArtId = uuid() - const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; + const coverArtId = uuid(); + const covertArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; const streamResponse = { status: 200, @@ -3021,9 +3314,9 @@ describe("SubsonicGenericMusicLibrary", () => { data: Buffer.from("the image", "ascii"), }; - mockGET - - .mockImplementationOnce(() => Promise.resolve(streamResponse)); + mockAxios.mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); const result = await generic.coverArt(covertArtURN); @@ -3032,27 +3325,25 @@ describe("SubsonicGenericMusicLibrary", () => { data: streamResponse.data, }); - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, + expect(mockAxios).toHaveBeenCalledWith( + streamRequest({ + url: `/rest/getCoverArt`, + params: { id: coverArtId, - }), - headers, - responseType: "arraybuffer", - } + }, + }) ); }); describe("and an error occurs fetching the uri", () => { it("should return undefined", async () => { - const coverArtId = uuid() - const covertArtURN = { system:"subsonic", resource: `art:${coverArtId}` }; + const coverArtId = uuid(); + const covertArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; - mockGET - - .mockImplementationOnce(() => Promise.reject("BOOOM")); + mockAxios.mockImplementationOnce(() => Promise.reject("BOOOM")); const result = await generic.coverArt(covertArtURN); @@ -3065,8 +3356,11 @@ describe("SubsonicGenericMusicLibrary", () => { const size = 189; it("should fetch the image", async () => { - const coverArtId = uuid() - const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; + const coverArtId = uuid(); + const covertArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; const streamResponse = { status: 200, @@ -3076,9 +3370,9 @@ describe("SubsonicGenericMusicLibrary", () => { data: Buffer.from("the image", "ascii"), }; - mockGET - - .mockImplementationOnce(() => Promise.resolve(streamResponse)); + mockAxios.mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); const result = await generic.coverArt(covertArtURN, size); @@ -3087,28 +3381,26 @@ describe("SubsonicGenericMusicLibrary", () => { data: streamResponse.data, }); - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, + expect(mockAxios).toHaveBeenCalledWith( + streamRequest({ + url: `/rest/getCoverArt`, + params: { id: coverArtId, - size - }), - headers, - responseType: "arraybuffer", - } + size, + }, + }) ); }); describe("and an error occurs fetching the uri", () => { it("should return undefined", async () => { - const coverArtId = uuid() - const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; + const coverArtId = uuid(); + const covertArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; - mockGET - - .mockImplementationOnce(() => Promise.reject("BOOOM")); + mockAxios.mockImplementationOnce(() => Promise.reject("BOOOM")); const result = await generic.coverArt(covertArtURN, size); @@ -3122,7 +3414,8 @@ describe("SubsonicGenericMusicLibrary", () => { describe("rate", () => { const trackId = uuid(); - const rate = (trackId: string, rating: Rating) => generic.rate(trackId, rating); + const rate = (trackId: string, rating: Rating) => + generic.rate(trackId, rating); const artist = anArtist(); const album = anAlbum({ id: "album1", name: "Burnin", genre: POP }); @@ -3137,8 +3430,7 @@ describe("SubsonicGenericMusicLibrary", () => { rating: { love: false, stars: 0 }, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -3151,11 +3443,14 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual(true); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/star`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/star`, + params: { ...authParamsPlusJson, id: trackId, - }), + }, headers, }); }); @@ -3170,8 +3465,7 @@ describe("SubsonicGenericMusicLibrary", () => { rating: { love: true, stars: 0 }, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -3184,11 +3478,14 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual(true); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/unstar`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/unstar`, + params: { ...authParamsPlusJson, id: trackId, - }), + }, headers, }); }); @@ -3203,8 +3500,7 @@ describe("SubsonicGenericMusicLibrary", () => { rating: { love: true, stars: 0 }, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -3216,7 +3512,7 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual(true); - expect(mockGET).toHaveBeenCalledTimes(2); + expect(mockAxios).toHaveBeenCalledTimes(2); }); }); @@ -3229,8 +3525,7 @@ describe("SubsonicGenericMusicLibrary", () => { rating: { love: false, stars: 0 }, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -3243,12 +3538,15 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual(true); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/setRating`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/setRating`, + params: { ...authParamsPlusJson, id: trackId, rating: 3, - }), + }, headers, }); }); @@ -3263,8 +3561,7 @@ describe("SubsonicGenericMusicLibrary", () => { rating: { love: true, stars: 3 }, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -3276,7 +3573,7 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual(true); - expect(mockGET).toHaveBeenCalledTimes(2); + expect(mockAxios).toHaveBeenCalledTimes(2); }); }); @@ -3289,8 +3586,7 @@ describe("SubsonicGenericMusicLibrary", () => { rating: { love: true, stars: 3 }, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -3304,19 +3600,25 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual(true); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/unstar`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/unstar`, + params: { ...authParamsPlusJson, id: trackId, - }), + }, headers, }); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/setRating`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/setRating`, + params: { ...authParamsPlusJson, id: trackId, rating: 5, - }), + }, headers, }); }); @@ -3325,8 +3627,6 @@ describe("SubsonicGenericMusicLibrary", () => { describe("invalid star values", () => { describe("stars of -1", () => { it("should return false", async () => { - mockGET; - const result = await rate(trackId, { love: true, stars: -1 }); expect(result).toEqual(false); }); @@ -3334,8 +3634,6 @@ describe("SubsonicGenericMusicLibrary", () => { describe("stars of 6", () => { it("should return false", async () => { - mockGET; - const result = await rate(trackId, { love: true, stars: -1 }); expect(result).toEqual(false); }); @@ -3344,7 +3642,7 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when fails", () => { it("should return false", async () => { - mockGET + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(FAILURE))) .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); @@ -3361,19 +3659,21 @@ describe("SubsonicGenericMusicLibrary", () => { it("should return true", async () => { const id = uuid(); - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); const result = await generic.scrobble(id); expect(result).toEqual(true); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/scrobble`, + params: { ...authParamsPlusJson, id, submission: true, - }), + }, headers, }); }); @@ -3383,25 +3683,26 @@ describe("SubsonicGenericMusicLibrary", () => { it("should return false", async () => { const id = uuid(); - mockGET - - .mockImplementationOnce(() => - Promise.resolve({ - status: 500, - data: {}, - }) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve({ + status: 500, + data: {}, + }) + ); const result = await generic.scrobble(id); expect(result).toEqual(false); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/scrobble`, + params: { ...authParamsPlusJson, id, submission: true, - }), + }, headers, }); }); @@ -3413,20 +3714,21 @@ describe("SubsonicGenericMusicLibrary", () => { it("should return true", async () => { const id = uuid(); - mockGET - - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); const result = await generic.nowPlaying(id); expect(result).toEqual(true); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/scrobble`, + params: { ...authParamsPlusJson, id, submission: false, - }), + }, headers, }); }); @@ -3436,25 +3738,26 @@ describe("SubsonicGenericMusicLibrary", () => { it("should return false", async () => { const id = uuid(); - mockGET - - .mockImplementationOnce(() => - Promise.resolve({ - status: 500, - data: {}, - }) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve({ + status: 500, + data: {}, + }) + ); const result = await generic.nowPlaying(id); expect(result).toEqual(false); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/scrobble`, + params: { ...authParamsPlusJson, id, submission: false, - }), + }, headers, }); }); @@ -3466,24 +3769,25 @@ describe("SubsonicGenericMusicLibrary", () => { it("should return true", async () => { const artist1 = anArtist({ name: "foo woo" }); - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ artists: [artist1] }))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ artists: [artist1] }))) + ); const result = await generic.searchArtists("foo"); expect(result).toEqual([artistToArtistSummary(artist1)]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { ...authParamsPlusJson, artistCount: 20, albumCount: 0, songCount: 0, query: "foo", - }), + }, headers, }); }); @@ -3494,13 +3798,11 @@ describe("SubsonicGenericMusicLibrary", () => { const artist1 = anArtist({ name: "foo woo" }); const artist2 = anArtist({ name: "foo choo" }); - mockGET - - .mockImplementationOnce(() => - Promise.resolve( - ok(getSearchResult3Json({ artists: [artist1, artist2] })) - ) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve( + ok(getSearchResult3Json({ artists: [artist1, artist2] })) + ) + ); const result = await generic.searchArtists("foo"); @@ -3509,14 +3811,17 @@ describe("SubsonicGenericMusicLibrary", () => { artistToArtistSummary(artist2), ]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { ...authParamsPlusJson, artistCount: 20, albumCount: 0, songCount: 0, query: "foo", - }), + }, headers, }); }); @@ -3524,24 +3829,25 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when there are no search results", () => { it("should return []", async () => { - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) + ); const result = await generic.searchArtists("foo"); expect(result).toEqual([]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { ...authParamsPlusJson, artistCount: 20, albumCount: 0, songCount: 0, query: "foo", - }), + }, headers, }); }); @@ -3557,26 +3863,27 @@ describe("SubsonicGenericMusicLibrary", () => { }); const artist = anArtist({ name: "#1", albums: [album] }); - mockGET - - .mockImplementationOnce(() => - Promise.resolve( - ok(getSearchResult3Json({ albums: [{ artist, album }] })) - ) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve( + ok(getSearchResult3Json({ albums: [{ artist, album }] })) + ) + ); const result = await generic.searchAlbums("foo"); expect(result).toEqual([albumToAlbumSummary(album)]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { ...authParamsPlusJson, artistCount: 0, albumCount: 20, songCount: 0, query: "foo", - }), + }, headers, }); }); @@ -3596,20 +3903,18 @@ describe("SubsonicGenericMusicLibrary", () => { }); const artist2 = anArtist({ name: "artist2", albums: [album2] }); - mockGET - - .mockImplementationOnce(() => - Promise.resolve( - ok( - getSearchResult3Json({ - albums: [ - { artist: artist1, album: album1 }, - { artist: artist2, album: album2 }, - ], - }) - ) + mockAxios.mockImplementationOnce(() => + Promise.resolve( + ok( + getSearchResult3Json({ + albums: [ + { artist: artist1, album: album1 }, + { artist: artist2, album: album2 }, + ], + }) ) - ); + ) + ); const result = await generic.searchAlbums("moo"); @@ -3618,14 +3923,17 @@ describe("SubsonicGenericMusicLibrary", () => { albumToAlbumSummary(album2), ]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { ...authParamsPlusJson, artistCount: 0, albumCount: 20, songCount: 0, query: "moo", - }), + }, headers, }); }); @@ -3633,24 +3941,25 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when there are no search results", () => { it("should return []", async () => { - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ albums: [] }))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ albums: [] }))) + ); const result = await generic.searchAlbums("foo"); expect(result).toEqual([]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { ...authParamsPlusJson, artistCount: 0, albumCount: 20, songCount: 0, query: "foo", - }), + }, headers, }); }); @@ -3674,8 +3983,7 @@ describe("SubsonicGenericMusicLibrary", () => { genre: pop, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSearchResult3Json({ tracks: [track] }))) ) @@ -3688,14 +3996,17 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual([track]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { ...authParamsPlusJson, artistCount: 0, albumCount: 0, songCount: 20, query: "foo", - }), + }, headers, }); }); @@ -3731,8 +4042,7 @@ describe("SubsonicGenericMusicLibrary", () => { genre: pop, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve( ok( @@ -3759,14 +4069,17 @@ describe("SubsonicGenericMusicLibrary", () => { expect(result).toEqual([track1, track2]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method:'get', + baseURL, + url: `/rest/search3`, + params: { ...authParamsPlusJson, artistCount: 0, albumCount: 0, songCount: 20, query: "moo", - }), + }, headers, }); }); @@ -3774,24 +4087,25 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when there are no search results", () => { it("should return []", async () => { - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ tracks: [] }))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ tracks: [] }))) + ); const result = await generic.searchTracks("foo"); expect(result).toEqual([]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { ...authParamsPlusJson, artistCount: 0, albumCount: 0, songCount: 20, query: "foo", - }), + }, headers, }); }); @@ -3804,18 +4118,19 @@ describe("SubsonicGenericMusicLibrary", () => { it("should return it", async () => { const playlist = aPlaylistSummary(); - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson([playlist]))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson([playlist]))) + ); const result = await generic.playlists(); expect(result).toEqual([playlist]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getPlaylists`, + params: authParamsPlusJson, headers, }); }); @@ -3828,18 +4143,19 @@ describe("SubsonicGenericMusicLibrary", () => { const playlist3 = aPlaylistSummary(); const playlists = [playlist1, playlist2, playlist3]; - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson(playlists))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson(playlists))) + ); const result = await generic.playlists(); expect(result).toEqual(playlists); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getPlaylists`, + params: authParamsPlusJson, headers, }); }); @@ -3847,18 +4163,19 @@ describe("SubsonicGenericMusicLibrary", () => { describe("when there are no playlists", () => { it("should return []", async () => { - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson([]))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson([]))) + ); const result = await generic.playlists(); expect(result).toEqual([]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: asURLSearchParams(authParamsPlusJson), + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getPlaylists`, + params: authParamsPlusJson, headers, }); }); @@ -3870,15 +4187,13 @@ describe("SubsonicGenericMusicLibrary", () => { it("should raise error", async () => { const id = "id404"; - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(error("70", "data not found"))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(error("70", "data not found"))) + ); - return expect( - generic.playlist(id) - ).rejects.toEqual("Subsonic error:data not found"); + return expect(generic.playlist(id)).rejects.toEqual( + "Subsonic error:data not found" + ); }); }); @@ -3915,19 +4230,17 @@ describe("SubsonicGenericMusicLibrary", () => { album: albumToAlbumSummary(album2), }); - mockGET - - .mockImplementationOnce(() => - Promise.resolve( - ok( - getPlayListJson({ - id, - name, - entries: [track1, track2], - }) - ) + mockAxios.mockImplementationOnce(() => + Promise.resolve( + ok( + getPlayListJson({ + id, + name, + entries: [track1, track2], + }) ) - ); + ) + ); const result = await generic.playlist(id); @@ -3940,11 +4253,14 @@ describe("SubsonicGenericMusicLibrary", () => { ], }); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getPlaylist`, + params: { ...authParamsPlusJson, id, - }), + }, headers, }); }); @@ -3956,21 +4272,22 @@ describe("SubsonicGenericMusicLibrary", () => { entries: [], }); - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListJson(playlist))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListJson(playlist))) + ); const result = await generic.playlist(playlist.id); expect(result).toEqual(playlist); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getPlaylist`, + params: { ...authParamsPlusJson, id: playlist.id, - }), + }, headers, }); }); @@ -3983,22 +4300,23 @@ describe("SubsonicGenericMusicLibrary", () => { const name = "ThePlaylist"; const id = uuid(); - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(createPlayListJson({ id, name }))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(createPlayListJson({ id, name }))) + ); const result = await generic.createPlaylist(name); expect(result).toEqual({ id, name }); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/createPlaylist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/createPlaylist`, + params: { ...authParamsPlusJson, f: "json", name, - }), + }, headers, }); }); @@ -4008,19 +4326,20 @@ describe("SubsonicGenericMusicLibrary", () => { it("should delete the playlist by id", async () => { const id = "id-to-delete"; - mockGET - - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - const result = await generic.deletePlaylist(id); + const result = await generic.deletePlaylist(id); expect(result).toEqual(true); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/deletePlaylist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/deletePlaylist`, + params: { ...authParamsPlusJson, id, - }), + }, headers, }); }); @@ -4032,20 +4351,21 @@ describe("SubsonicGenericMusicLibrary", () => { const playlistId = uuid(); const trackId = uuid(); - mockGET - - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - const result = await generic.addToPlaylist(playlistId, trackId); + const result = await generic.addToPlaylist(playlistId, trackId); expect(result).toEqual(true); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/updatePlaylist`, + params: { ...authParamsPlusJson, playlistId, songIdToAdd: trackId, - }), + }, headers, }); }); @@ -4056,20 +4376,21 @@ describe("SubsonicGenericMusicLibrary", () => { const playlistId = uuid(); const indicies = [6, 100, 33]; - mockGET - - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - const result = await generic.removeFromPlaylist(playlistId, indicies); + const result = await generic.removeFromPlaylist(playlistId, indicies); expect(result).toEqual(true); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/updatePlaylist`, + params: { ...authParamsPlusJson, playlistId, songIndexToRemove: indicies, - }), + }, headers, }); }); @@ -4097,8 +4418,7 @@ describe("SubsonicGenericMusicLibrary", () => { genre: pop, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSimilarSongsJson([track1]))) ) @@ -4106,17 +4426,20 @@ describe("SubsonicGenericMusicLibrary", () => { Promise.resolve(ok(getAlbumJson(artist1, album1, []))) ); - const result = await generic.similarSongs(id); + const result = await generic.similarSongs(id); expect(result).toEqual([track1]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getSimilarSongs2`, + params: { ...authParams, f: "json", id, count: 50, - }), + }, headers, }); }); @@ -4160,8 +4483,7 @@ describe("SubsonicGenericMusicLibrary", () => { genre: pop, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getSimilarSongsJson([track1, track2, track3]))) ) @@ -4175,17 +4497,20 @@ describe("SubsonicGenericMusicLibrary", () => { Promise.resolve(ok(getAlbumJson(artist1, album1, []))) ); - const result = await generic.similarSongs(id); + const result = await generic.similarSongs(id); expect(result).toEqual([track1, track2, track3]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getSimilarSongs2`, + params: { ...authParams, f: "json", id, count: 50, - }), + }, headers, }); }); @@ -4195,23 +4520,24 @@ describe("SubsonicGenericMusicLibrary", () => { it("should return []", async () => { const id = "idWithNoTracks"; - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(getSimilarSongsJson([]))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([]))) + ); - const result = await generic.similarSongs(id); + const result = await generic.similarSongs(id); expect(result).toEqual([]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getSimilarSongs2`, + params: { ...authParams, f: "json", id, count: 50, - }), + }, headers, }); }); @@ -4221,15 +4547,13 @@ describe("SubsonicGenericMusicLibrary", () => { it("should fail", async () => { const id = "idThatHasAnError"; - mockGET - - .mockImplementationOnce(() => - Promise.resolve(ok(error("70", "data not found"))) - ); + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(error("70", "data not found"))) + ); - return expect( - generic.similarSongs(id) - ).rejects.toEqual("Subsonic error:data not found"); + return expect(generic.similarSongs(id)).rejects.toEqual( + "Subsonic error:data not found" + ); }); }); }); @@ -4254,8 +4578,7 @@ describe("SubsonicGenericMusicLibrary", () => { genre: pop, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getArtistJson(artist))) ) @@ -4266,17 +4589,20 @@ describe("SubsonicGenericMusicLibrary", () => { Promise.resolve(ok(getAlbumJson(artist, album1, []))) ); - const result = await generic.topSongs(artistId); + const result = await generic.topSongs(artistId); expect(result).toEqual([track1]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getTopSongs`, + params: { ...authParams, f: "json", artist: artistName, count: 50, - }), + }, headers, }); }); @@ -4314,8 +4640,7 @@ describe("SubsonicGenericMusicLibrary", () => { genre: POP, }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getArtistJson(artist))) ) @@ -4332,17 +4657,20 @@ describe("SubsonicGenericMusicLibrary", () => { Promise.resolve(ok(getAlbumJson(artist, album1, []))) ); - const result = await generic.topSongs(artistId); + const result = await generic.topSongs(artistId); expect(result).toEqual([track1, track2, track3]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getTopSongs`, + params: { ...authParams, f: "json", artist: artistName, count: 50, - }), + }, headers, }); }); @@ -4361,8 +4689,7 @@ describe("SubsonicGenericMusicLibrary", () => { albums: [album1], }); - mockGET - + mockAxios .mockImplementationOnce(() => Promise.resolve(ok(getArtistJson(artist))) ) @@ -4370,20 +4697,23 @@ describe("SubsonicGenericMusicLibrary", () => { Promise.resolve(ok(getTopSongsJson([]))) ); - - const result = await generic.topSongs(artistId); + const result = await generic.topSongs(artistId); expect(result).toEqual([]); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { - params: asURLSearchParams({ + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get' , + baseURL, + url: `/rest/getTopSongs`, + params: { ...authParams, f: "json", artist: artistName, count: 50, - }), + }, headers, - }); + } + ); }); }); }); From 1b14b88fb4840e3ebdf82a26657eee0756de1e09 Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 23 Apr 2022 14:23:20 +1000 Subject: [PATCH 13/18] test workings --- src/http.ts | 48 +--------- src/subsonic/http.ts | 3 +- src/subsonic/index.ts | 3 +- src/subsonic/library.ts | 3 +- tests/http.test.ts | 170 +++------------------------------ tests/subsonic/generic.test.ts | 2 +- 6 files changed, 19 insertions(+), 210 deletions(-) diff --git a/src/http.ts b/src/http.ts index 8801df9..fbc9f66 100644 --- a/src/http.ts +++ b/src/http.ts @@ -11,56 +11,10 @@ export interface Http { (config: AxiosRequestConfig): AxiosPromise; } -// export const http = -// (base: Http = axios, modifier: RequestModifier = no_op): Http => -// (config: AxiosRequestConfig) => { -// console.log( -// `applying ${JSON.stringify(config)} onto ${JSON.stringify(modifier)}` -// ); -// const result = modifier(config); -// console.log(`result is ${JSON.stringify(result)}`); -// return base(result); -// }; - -// export const chain = -// (...modifiers: RequestModifier[]): RequestModifier => -// (config: AxiosRequestConfig) => -// modifiers.reduce( -// (config: AxiosRequestConfig, next: RequestModifier) => next(config), -// config -// ); - -// export const baseUrl = (baseURL: string) => (config: AxiosRequestConfig) => ({ -// ...config, -// baseURL, -// }); - -// export const axiosConfig = -// (additionalConfig: Partial) => -// (config: AxiosRequestConfig) => ({ ...config, ...additionalConfig }); - -// export const params = (params: any) => (config: AxiosRequestConfig) => { -// console.log( -// `params on config ${JSON.stringify( -// config.params -// )}, params applying ${JSON.stringify(params)}` -// ); -// const after = { ...config, params: { ...config.params, ...params } }; -// console.log(`params after ${JSON.stringify(after.params)}`); -// return after; -// }; - -// export const headers = (headers: any) => (config: AxiosRequestConfig) => ({ -// ...config, -// headers: { ...config.headers, ...headers }, -// }); -// export const formatJson = (): RequestModifier => (config: AxiosRequestConfig) => ({...config, params: { ...config.params, f: 'json' } }); -// export const subsonicAuth = (credentials: { username: string, password: string}) => (config: AxiosRequestConfig) => ({...config, params: { ...config.params, u: credentials.username, ...t_and_s(credentials.password) } }); - export type RequestParams = { baseURL: string; url: string, params: any, headers: any, responseType: ResponseType } // todo: rename to http -export const http2 = +export const http = (base: Http, defaults: Partial): Http => (config: AxiosRequestConfig) => { let toApply = { diff --git a/src/subsonic/http.ts b/src/subsonic/http.ts index 47b1d7c..57acfdb 100644 --- a/src/subsonic/http.ts +++ b/src/subsonic/http.ts @@ -6,7 +6,8 @@ import { t_and_s, USER_AGENT, } from "."; -import { Http, http2 } from "../http"; +// todo: rename http2 to http +import { Http, http as http2 } from "../http"; import { Credentials } from "../music_service"; import { asURLSearchParams } from "../utils"; diff --git a/src/subsonic/index.ts b/src/subsonic/index.ts index 3aee9f0..ffaf7a4 100644 --- a/src/subsonic/index.ts +++ b/src/subsonic/index.ts @@ -4,7 +4,8 @@ import { Md5 } from "ts-md5/dist/md5"; import axios from "axios"; import randomstring from "randomstring"; import _ from "underscore"; -import { Http, http2 } from "../http"; +// todo: rename http2 to http +import { Http, http as http2 } from "../http"; import { Credentials, diff --git a/src/subsonic/library.ts b/src/subsonic/library.ts index 95de93e..15873b8 100644 --- a/src/subsonic/library.ts +++ b/src/subsonic/library.ts @@ -37,7 +37,8 @@ import Subsonic, { import axios from "axios"; import { asURLSearchParams } from "../utils"; import { artistSummaryFromNDArtist, NDArtist } from "./navidrome"; -import { Http, http2 } from "../http"; +//todo: rename http2 -> http +import { Http, http as http2 } from "../http"; import { getRaw2 } from "./http"; type album = { diff --git a/tests/http.test.ts b/tests/http.test.ts index 5b0f595..85f69c6 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -1,154 +1,6 @@ -import { - - http2, -} from "../src/http"; +import { http, } from "../src/http"; -// describe("request modifiers", () => { -// describe("baseUrl", () => { -// it.each([ -// [ -// { data: "bob" }, -// "http://example.com", -// { data: "bob", baseURL: "http://example.com" }, -// ], -// [ -// { baseURL: "http://originalBaseUrl.example.com" }, -// "http://example.com", -// { baseURL: "http://example.com" }, -// ], -// ])( -// "should apply the baseUrl", -// (requestConfig: any, value: string, expected: any) => { -// expect(baseUrl(value)(requestConfig)).toEqual(expected); -// } -// ); -// }); - -// describe("params", () => { -// it.each([ -// [ -// { data: "bob" }, -// { param1: "value1", param2: "value2" }, -// { data: "bob", params: { param1: "value1", param2: "value2" } }, -// ], -// [ -// { data: "bob", params: { orig1: "origValue1" } }, -// {}, -// { data: "bob", params: { orig1: "origValue1" } }, -// ], -// [ -// { data: "bob", params: { orig1: "origValue1" } }, -// { param1: "value1", param2: "value2" }, -// { -// data: "bob", -// params: { orig1: "origValue1", param1: "value1", param2: "value2" }, -// }, -// ], -// ])( -// "should apply the params", -// (requestConfig: any, newParams: any, expected: any) => { -// expect(params(newParams)(requestConfig)).toEqual(expected); -// } -// ); -// }); - -// describe("headers", () => { -// it.each([ -// [ -// { data: "bob" }, -// { h1: "value1", h2: "value2" }, -// { data: "bob", headers: { h1: "value1", h2: "value2" } }, -// ], -// [ -// { data: "bob", headers: { orig1: "origValue1" } }, -// {}, -// { data: "bob", headers: { orig1: "origValue1" } }, -// ], -// [ -// { data: "bob", headers: { orig1: "origValue1" } }, -// { h1: "value1", h2: "value2" }, -// { -// data: "bob", -// headers: { orig1: "origValue1", h1: "value1", h2: "value2" }, -// }, -// ], -// ])( -// "should apply the headers", -// (requestConfig: any, newParams: any, expected: any) => { -// expect(headers(newParams)(requestConfig)).toEqual(expected); -// } -// ); -// }); - -// describe("chain", () => { -// it.each([ -// [ -// { data: "bob" }, -// [params({ param1: "value1", param2: "value2" })], -// { data: "bob", params: { param1: "value1", param2: "value2" } }, -// ], -// [ -// { data: "bob" }, -// [params({ param1: "value1" }), params({ param2: "value2" })], -// { data: "bob", params: { param1: "value1", param2: "value2" } }, -// ], -// [{ data: "bob" }, [], { data: "bob" }], -// ])( -// "should apply the chain", -// (requestConfig: any, newParams: RequestModifier[], expected: any) => { -// expect(chain(...newParams)(requestConfig)).toEqual(expected); -// } -// ); -// }); - -// describe("wrapping", () => { -// const mockAxios = jest.fn(); - -// describe("baseURL", () => { -// const base = http( -// mockAxios, -// baseUrl("http://original.example.com") -// ); - -// describe("when no baseURL passed in when being invoked", () => { -// it("should use the original value", () => { -// base({}) -// expect(mockAxios).toHaveBeenCalledWith({ baseURL: "http://original.example.com" }); -// }); -// }); - -// describe("when a new baseURL is passed in when being invoked", () => { -// it("should use the new value", () => { -// base({ baseURL: "http://new.example.com" }) -// expect(mockAxios).toHaveBeenCalledWith({ baseURL: "http://new.example.com" }); -// }); -// }); -// }); - -// describe("params", () => { -// const base = http( -// mockAxios, -// params({ a: "1", b: "2" }) -// ); - -// it("should apply the modified when invoked", () => { -// base({ method: 'get' }); -// expect(mockAxios).toHaveBeenCalledWith({ method: 'get', params: { a: "1", b: "2" }}); -// }); - -// describe("wrapping the base", () => { -// const wrapped = http(base, params({ b: "2b", c: "3" })); - -// it("should the wrapped values as priority", () => { -// wrapped({ method: 'get', params: { a: "1b", c: "3b", d: "4" } }); -// expect(mockAxios).toHaveBeenCalledWith({ method: 'get', params: { a: "1b", b: "2b", c: "3b", d: "4" }}); -// }); -// }); -// }); -// }); -// }); - -describe("http2", () => { +describe("http", () => { const mockAxios = jest.fn(); beforeEach(() => { @@ -166,7 +18,7 @@ describe("http2", () => { return thing; }; - const base = http2(mockAxios, getValue('base')); + const base = http(mockAxios, getValue('base')); describe("using default", () => { it("should use the default", () => { @@ -183,8 +35,8 @@ describe("http2", () => { }); describe("wrapping", () => { - const firstLayer = http2(base, getValue('level1')); - const secondLayer = http2(firstLayer, getValue('level2')); + const firstLayer = http(base, getValue('level1')); + const secondLayer = http(firstLayer, getValue('level2')); describe("when the outter call provides a value", () => { it("should apply it", () => { @@ -203,7 +55,7 @@ describe("http2", () => { }); describe("requestType", () => { - const base = http2(mockAxios, { responseType: 'stream' }); + const base = http(mockAxios, { responseType: 'stream' }); describe("using default", () => { it("should use the default", () => { @@ -220,8 +72,8 @@ describe("http2", () => { }); describe("wrapping", () => { - const firstLayer = http2(base, { responseType: 'arraybuffer' }); - const secondLayer = http2(firstLayer, { responseType: 'blob' }); + const firstLayer = http(base, { responseType: 'arraybuffer' }); + const secondLayer = http(firstLayer, { responseType: 'blob' }); describe("when the outter call provides a value", () => { it("should apply it", () => { @@ -248,7 +100,7 @@ describe("http2", () => { thing[field] = values; return thing; } - const base = http2(mockAxios, getValues({ a: 1, b: 2, c: 3, d: 4 })); + const base = http(mockAxios, getValues({ a: 1, b: 2, c: 3, d: 4 })); describe("using default", () => { it("should use the default", () => { @@ -265,8 +117,8 @@ describe("http2", () => { }); describe("wrapping", () => { - const firstLayer = http2(base, getValues({ b: 22 })); - const secondLayer = http2(firstLayer, getValues({ c: 33 })); + const firstLayer = http(base, getValues({ b: 22 })); + const secondLayer = http(firstLayer, getValues({ c: 33 })); describe("when the outter call provides a value", () => { it("should apply it", () => { diff --git a/tests/subsonic/generic.test.ts b/tests/subsonic/generic.test.ts index f99c6d5..3654c41 100644 --- a/tests/subsonic/generic.test.ts +++ b/tests/subsonic/generic.test.ts @@ -46,7 +46,7 @@ import { import { EMPTY, error, FAILURE, subsonicOK, ok } from "../subsonic.test"; import Subsonic, { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; import { b64Encode } from "../../src/b64"; -import { http2 } from "../../src/http"; +import { http as http2 } from "../../src/http"; const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => pipe( From 730524d7a1bd4831b76561d76b58f37ac6fc86b8 Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 23 Apr 2022 16:46:52 +1000 Subject: [PATCH 14/18] no more subsonic in the library --- src/subsonic/index.ts | 12 +- src/subsonic/library.ts | 274 ++++++++++++--------- src/subsonic/{http.ts => subsonic_http.ts} | 23 -- tests/subsonic/generic.test.ts | 11 +- 4 files changed, 165 insertions(+), 155 deletions(-) rename src/subsonic/{http.ts => subsonic_http.ts} (79%) diff --git a/src/subsonic/index.ts b/src/subsonic/index.ts index ffaf7a4..efcdbe0 100644 --- a/src/subsonic/index.ts +++ b/src/subsonic/index.ts @@ -17,7 +17,7 @@ import { import { b64Encode, b64Decode } from "../b64"; import { axiosImageFetcher, ImageFetcher } from "../images"; import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./library"; -import { http, getJSON as getJSON2 } from "./http"; +import { getJSON as getJSON2 } from "./subsonic_http"; export const t = (password: string, s: string) => Md5.hashStr(`${password}${s}`); @@ -61,6 +61,7 @@ export function isError( return (subsonicResponse as SubsonicError).error !== undefined; } +// todo: is this a good name? export type StreamClientApplication = (track: Track) => string; export const DEFAULT_CLIENT_APPLICATION = "bonob"; @@ -94,9 +95,12 @@ export interface SubsonicMusicLibrary extends MusicLibrary { export class Subsonic implements MusicService { url: string; + + // todo: does this need to be in here now? streamClientApplication: StreamClientApplication; // todo: why is this in here? externalImageFetcher: ImageFetcher; + base: Http; constructor( @@ -114,9 +118,6 @@ export class Subsonic implements MusicService { }); } - // todo: delete - http = (credentials: Credentials) => http(this.url, credentials); - authenticated = (credentials: Credentials, wrap: Http = this.base) => http2(wrap, { params: { @@ -168,8 +169,7 @@ export class Subsonic implements MusicService { credentials: SubsonicCredentials ): Promise => { const subsonicGenericLibrary = new SubsonicGenericMusicLibrary( - this, - credentials, + this.streamClientApplication, this.authenticated(credentials, this.base) ); if (credentials.type == "navidrome") { diff --git a/src/subsonic/library.ts b/src/subsonic/library.ts index 15873b8..38f240a 100644 --- a/src/subsonic/library.ts +++ b/src/subsonic/library.ts @@ -27,8 +27,9 @@ import { Sortable, Track, } from "../music_service"; -import Subsonic, { +import { DODGY_IMAGE_NAME, + StreamClientApplication, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, @@ -38,8 +39,8 @@ import axios from "axios"; import { asURLSearchParams } from "../utils"; import { artistSummaryFromNDArtist, NDArtist } from "./navidrome"; //todo: rename http2 -> http -import { Http, http as http2 } from "../http"; -import { getRaw2 } from "./http"; +import { Http, http as http2, RequestParams } from "../http"; +import { getRaw2, getJSON as getJSON2 } from "./subsonic_http"; type album = { id: string; @@ -275,20 +276,22 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined => ); export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { - subsonic: Subsonic; - credentials: SubsonicCredentials; + streamClientApplication: StreamClientApplication; http: Http; constructor( - subsonic: Subsonic, - credentials: SubsonicCredentials, + streamClientApplication: StreamClientApplication, http: Http ) { - this.subsonic = subsonic; - this.credentials = credentials; + this.streamClientApplication = streamClientApplication; this.http = http; } + GET = (query: Partial) => ({ + asRAW: () => getRaw2(http2(this.http, query)), + asJSON: () => getJSON2(http2(this.http, query)), + }); + flavour = () => "subsonic"; bearerToken = (_: Credentials): TE.TaskEither => @@ -315,8 +318,10 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { album = (id: string): Promise => this.getAlbum(id); genres = () => - this.subsonic - .getJSON(this.credentials, "/rest/getGenres") + this.GET({ + url: "/rest/getGenres", + }) + .asJSON() .then((it) => pipe( it.genres.genre || [], @@ -328,10 +333,13 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { ); tracks = (albumId: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getAlbum", { + this.GET({ + url: "/rest/getAlbum", + params: { id: albumId, - }) + }, + }) + .asJSON() .then((it) => it.album) .then((album) => (album.song || []).map((song) => asTrack(asAlbum(album), song)) @@ -352,21 +360,23 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { const thingsToUpdate = []; if (track.rating.love != rating.love) { thingsToUpdate.push( - this.subsonic.getJSON( - this.credentials, - `/rest/${rating.love ? "star" : "unstar"}`, - { + this.GET({ + url: `/rest/${rating.love ? "star" : "unstar"}`, + params: { id: trackId, - } - ) + }, + }).asJSON() ); } if (track.rating.stars != rating.stars) { thingsToUpdate.push( - this.subsonic.getJSON(this.credentials, `/rest/setRating`, { - id: trackId, - rating: rating.stars, - }) + this.GET({ + url: `/rest/setRating`, + params: { + id: trackId, + rating: rating.stars, + }, + }).asJSON() ); } return Promise.all(thingsToUpdate); @@ -383,27 +393,26 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { }) => // todo: all these headers and stuff can be rolled into httpeee this.getTrack(trackId).then((track) => - getRaw2( - http2(this.http, { - url: `/rest/stream`, - params: { - id: trackId, - c: this.subsonic.streamClientApplication(track), - }, - headers: pipe( - range, - O.fromNullable, - O.map((range) => ({ - // "User-Agent": USER_AGENT, - Range: range, - })), - O.getOrElse(() => ({ - // "User-Agent": USER_AGENT, - })) - ), - responseType: "stream", - }) - ) + this.GET({ + url: "/rest/stream", + params: { + id: trackId, + c: this.streamClientApplication(track), + }, + headers: pipe( + range, + O.fromNullable, + O.map((range) => ({ + // "User-Agent": USER_AGENT, + Range: range, + })), + O.getOrElse(() => ({ + // "User-Agent": USER_AGENT, + })) + ), + responseType: "stream", + }) + .asRAW() .then((res) => ({ status: res.status, headers: { @@ -420,7 +429,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { Promise.resolve(coverArtURN) .then((it) => assertSystem(it, "subsonic")) .then((it) => it.resource.split(":")[1]!) - .then((it) => this.getCoverArt(this.credentials, it, size)) + .then((it) => this.getCoverArt(it, size)) .then((res) => ({ contentType: res.headers["content-type"], data: Buffer.from(res.data, "binary"), @@ -433,20 +442,26 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { }); scrobble = async (id: string) => - this.subsonic - .getJSON(this.credentials, `/rest/scrobble`, { + this.GET({ + url: `/rest/scrobble`, + params: { id, submission: true, - }) + }, + }) + .asJSON() .then((_) => true) .catch(() => false); nowPlaying = async (id: string) => - this.subsonic - .getJSON(this.credentials, `/rest/scrobble`, { + this.GET({ + url: `/rest/scrobble`, + params: { id, submission: false, - }) + }, + }) + .asJSON() .then((_) => true) .catch(() => false); @@ -473,18 +488,21 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { ); playlists = async () => - this.subsonic - .getJSON(this.credentials, "/rest/getPlaylists") + this.GET({ url: "/rest/getPlaylists" }) + .asJSON() .then((it) => it.playlists.playlist || []) .then((playlists) => playlists.map((it) => ({ id: it.id, name: it.name })) ); playlist = async (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getPlaylist", { + this.GET({ + url: "/rest/getPlaylist", + params: { id, - }) + }, + }) + .asJSON() .then((it) => it.playlist) .then((playlist) => { let trackNumber = 1; @@ -510,43 +528,54 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { }); createPlaylist = async (name: string) => - this.subsonic - .getJSON(this.credentials, "/rest/createPlaylist", { + this.GET({ + url: "/rest/createPlaylist", + params: { name, - }) + }, + }) + .asJSON() .then((it) => it.playlist) .then((it) => ({ id: it.id, name: it.name })); deletePlaylist = async (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/deletePlaylist", { + this.GET({ + url: "/rest/deletePlaylist", + params: { id, - }) + }, + }) + .asJSON() .then((_) => true); addToPlaylist = async (playlistId: string, trackId: string) => - this.subsonic - .getJSON(this.credentials, "/rest/updatePlaylist", { + this.GET({ + url: "/rest/updatePlaylist", + params: { playlistId, songIdToAdd: trackId, - }) + }, + }) + .asJSON() .then((_) => true); removeFromPlaylist = async (playlistId: string, indicies: number[]) => - this.subsonic - .getJSON(this.credentials, "/rest/updatePlaylist", { + this.GET({ + url: "/rest/updatePlaylist", + params: { playlistId, songIndexToRemove: indicies, - }) + }, + }) + .asJSON() .then((_) => true); similarSongs = async (id: string) => - this.subsonic - .getJSON( - this.credentials, - "/rest/getSimilarSongs2", - { id, count: 50 } - ) + this.GET({ + url: "/rest/getSimilarSongs2", + params: { id, count: 50 }, + }) + .asJSON() .then((it) => it.similarSongs2.song || []) .then((songs) => Promise.all( @@ -558,11 +587,14 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { topSongs = async (artistId: string) => this.getArtist(artistId).then(({ name }) => - this.subsonic - .getJSON(this.credentials, "/rest/getTopSongs", { + this.GET({ + url: "/rest/getTopSongs", + params: { artist: name, count: 50, - }) + }, + }) + .asJSON() .then((it) => it.topSongs.song || []) .then((songs) => Promise.all( @@ -576,8 +608,8 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { private getArtists = (): Promise< (IdName & { albumCount: number; image: BUrn | undefined })[] > => - this.subsonic - .getJSON(this.credentials, "/rest/getArtists") + this.GET({ url: "/rest/getArtists" }) + .asJSON() .then((it) => (it.artists.index || []).flatMap((it) => it.artist || [])) .then((artists) => artists.map((artist) => ({ @@ -601,16 +633,15 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { l: string | undefined; }; }> => - this.subsonic - .getJSON( - this.credentials, - "/rest/getArtistInfo2", - { - id, - count: 50, - includeNotPresent: true, - } - ) + this.GET({ + url: "/rest/getArtistInfo2", + params: { + id, + count: 50, + includeNotPresent: true, + }, + }) + .asJSON() .then((it) => it.artistInfo2) .then((it) => ({ images: { @@ -630,8 +661,8 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { })); private getAlbum = (id: string): Promise => - this.subsonic - .getJSON(this.credentials, "/rest/getAlbum", { id }) + this.GET({ url: "/rest/getAlbum", params: { id } }) + .asJSON() .then((it) => it.album) .then((album) => ({ id: album.id, @@ -648,10 +679,13 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { ): Promise< IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] } > => - this.subsonic - .getJSON(this.credentials, "/rest/getArtist", { + this.GET({ + url: "/rest/getArtist", + params: { id, - }) + }, + }) + .asJSON() .then((it) => it.artist) .then((it) => ({ id: it.id, @@ -679,18 +713,21 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { }) ); - private getCoverArt = (credentials: Credentials, id: string, size?: number) => - getRaw2(http2(this.subsonic.authenticated(credentials), { + private getCoverArt = (id: string, size?: number) => + this.GET({ url: "/rest/getCoverArt", params: { id, size }, responseType: "arraybuffer", - })); + }).asRAW(); private getTrack = (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getSong", { + this.GET({ + url: "/rest/getSong", + params: { id, - }) + }, + }) + .asJSON() .then((it) => it.song) .then((song) => this.getAlbum(song.albumId!).then((album) => asTrack(album, song)) @@ -708,13 +745,16 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { })); private search3 = (q: any) => - this.subsonic - .getJSON(this.credentials, "/rest/search3", { + this.GET({ + url: "/rest/search3", + params: { artistCount: 0, albumCount: 0, songCount: 0, ...q, - }) + }, + }) + .asJSON() .then((it) => ({ artists: it.searchResult3.artist || [], albums: it.searchResult3.album || [], @@ -726,17 +766,16 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { this.getArtists().then((it) => inject(it, (total, artist) => total + artist.albumCount, 0) ), - this.subsonic - .getJSON( - this.credentials, - "/rest/getAlbumList2", - { - type: AlbumQueryTypeToSubsonicType[q.type], - ...(q.genre ? { genre: b64Decode(q.genre) } : {}), - size: 500, - offset: q._index, - } - ) + this.GET({ + url: "/rest/getAlbumList2", + params: { + type: AlbumQueryTypeToSubsonicType[q.type], + ...(q.genre ? { genre: b64Decode(q.genre) } : {}), + size: 500, + offset: q._index, + }, + }) + .asJSON() .then((response) => response.albumList2.album || []) .then(this.toAlbumSummary), ]).then(([total, albums]) => ({ @@ -758,11 +797,12 @@ export const navidromeMusicLibrary = ( pipe( TE.tryCatch( () => + // todo: not hardcode axios in here axios({ - method: 'post', + method: "post", baseURL: url, url: `/auth/login`, - data: _.pick(credentials, "username", "password") + data: _.pick(credentials, "username", "password"), }), () => new AuthFailure("Failed to get bearerToken") ), diff --git a/src/subsonic/http.ts b/src/subsonic/subsonic_http.ts similarity index 79% rename from src/subsonic/http.ts rename to src/subsonic/subsonic_http.ts index 57acfdb..8a9d975 100644 --- a/src/subsonic/http.ts +++ b/src/subsonic/subsonic_http.ts @@ -54,28 +54,6 @@ export interface HTTP { ): Promise; } -// export const basic = (opts : AxiosRequestConfig) => axios(opts); - -// function whatDoesItLookLike() { -// const basic = axios; - -// const authenticatedClient = httpee(axios, chain( -// baseUrl("http://foobar"), -// subsonicAuth({username: 'bob', password: 'foo'}) -// )); -// const jsonClient = httpee(authenticatedClient, formatJson()) - -// jsonClient({ }) - -// } - -// .then((response) => response.data as SubsonicEnvelope) -// .then((json) => json["subsonic-response"]) -// .then((json) => { -// if (isError(json)) throw `Subsonic error:${json.error.message}`; -// else return json as unknown as T; -// }); - export const raw = (response: AxiosPromise) => response .catch((e) => { @@ -87,7 +65,6 @@ export const raw = (response: AxiosPromise) => } else return response; }); - // todo: delete export const getRaw2 = (http: Http) => http({ method: "get" }) .catch((e) => { diff --git a/tests/subsonic/generic.test.ts b/tests/subsonic/generic.test.ts index 3654c41..e2039d7 100644 --- a/tests/subsonic/generic.test.ts +++ b/tests/subsonic/generic.test.ts @@ -44,7 +44,7 @@ import { SubsonicGenericMusicLibrary, } from "../../src/subsonic/library"; import { EMPTY, error, FAILURE, subsonicOK, ok } from "../subsonic.test"; -import Subsonic, { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; +import { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; import { b64Encode } from "../../src/b64"; import { http as http2 } from "../../src/http"; @@ -490,7 +490,6 @@ describe("SubsonicGenericMusicLibrary", () => { const salt = "saltysalty"; const streamClientApplication = jest.fn(); - const subsonic = new Subsonic(url, streamClientApplication) const authParams = { u: username, @@ -510,13 +509,7 @@ describe("SubsonicGenericMusicLibrary", () => { }; const generic = new SubsonicGenericMusicLibrary( - subsonic, - { - username, - password, - type: 'subsonic', - bearer: undefined - }, + streamClientApplication, // todo: all this stuff doesnt need to be defaulted in here. http2(mockAxios, { baseURL, From eb66393fe62367d406e905a6c988e1ded3218da2 Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 23 Apr 2022 16:54:30 +1000 Subject: [PATCH 15/18] tidy --- src/http.ts | 16 +++++----- src/subsonic/index.ts | 14 ++++----- src/subsonic/library.ts | 10 +++---- src/subsonic/subsonic_http.ts | 56 +---------------------------------- 4 files changed, 20 insertions(+), 76 deletions(-) diff --git a/src/http.ts b/src/http.ts index fbc9f66..0a6e561 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,19 +1,17 @@ import { AxiosPromise, AxiosRequestConfig, ResponseType } from "axios"; -import _ from "underscore"; - -export interface RequestModifier { - (config: AxiosRequestConfig): AxiosRequestConfig; -} - -export const no_op = (config: AxiosRequestConfig) => config; export interface Http { (config: AxiosRequestConfig): AxiosPromise; } -export type RequestParams = { baseURL: string; url: string, params: any, headers: any, responseType: ResponseType } +export type RequestParams = { + baseURL: string; + url: string; + params: any; + headers: any; + responseType: ResponseType; +}; -// todo: rename to http export const http = (base: Http, defaults: Partial): Http => (config: AxiosRequestConfig) => { diff --git a/src/subsonic/index.ts b/src/subsonic/index.ts index efcdbe0..c3e71ca 100644 --- a/src/subsonic/index.ts +++ b/src/subsonic/index.ts @@ -101,7 +101,7 @@ export class Subsonic implements MusicService { // todo: why is this in here? externalImageFetcher: ImageFetcher; - base: Http; + subsonicHttp: Http; constructor( url: string, @@ -111,15 +111,15 @@ export class Subsonic implements MusicService { this.url = url; this.streamClientApplication = streamClientApplication; this.externalImageFetcher = externalImageFetcher; - this.base = http2(axios, { + this.subsonicHttp = http2(axios, { baseURL: this.url, params: { v: "1.16.1", c: DEFAULT_CLIENT_APPLICATION }, headers: { "User-Agent": "bonob" }, }); } - authenticated = (credentials: Credentials, wrap: Http = this.base) => - http2(wrap, { + authenticatedSubsonicHttp = (credentials: Credentials) => + http2(this.subsonicHttp, { params: { u: credentials.username, ...t_and_s(credentials.password), @@ -130,12 +130,12 @@ export class Subsonic implements MusicService { credentials: Credentials, url: string, params: {} = {} - ): Promise => getJSON2(http2(this.authenticated(credentials), { url, params })); + ): Promise => getJSON2(http2(this.authenticatedSubsonicHttp(credentials), { url, params })); generateToken = (credentials: Credentials) => pipe( TE.tryCatch( - () => getJSON2(http2(this.authenticated(credentials), { url: "/rest/ping.view" })), + () => getJSON2(http2(this.authenticatedSubsonicHttp(credentials), { url: "/rest/ping.view" })), (e) => new AuthFailure(e as string) ), TE.chain(({ type }) => @@ -170,7 +170,7 @@ export class Subsonic implements MusicService { ): Promise => { const subsonicGenericLibrary = new SubsonicGenericMusicLibrary( this.streamClientApplication, - this.authenticated(credentials, this.base) + this.authenticatedSubsonicHttp(credentials) ); if (credentials.type == "navidrome") { return Promise.resolve( diff --git a/src/subsonic/library.ts b/src/subsonic/library.ts index 38f240a..e1f2ce7 100644 --- a/src/subsonic/library.ts +++ b/src/subsonic/library.ts @@ -277,19 +277,19 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined => export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { streamClientApplication: StreamClientApplication; - http: Http; + subsonicHttp: Http; constructor( streamClientApplication: StreamClientApplication, - http: Http + subsonicHttp: Http ) { this.streamClientApplication = streamClientApplication; - this.http = http; + this.subsonicHttp = subsonicHttp; } GET = (query: Partial) => ({ - asRAW: () => getRaw2(http2(this.http, query)), - asJSON: () => getJSON2(http2(this.http, query)), + asRAW: () => getRaw2(http2(this.subsonicHttp, query)), + asJSON: () => getJSON2(http2(this.subsonicHttp, query)), }); flavour = () => "subsonic"; diff --git a/src/subsonic/subsonic_http.ts b/src/subsonic/subsonic_http.ts index 8a9d975..4ed9a81 100644 --- a/src/subsonic/subsonic_http.ts +++ b/src/subsonic/subsonic_http.ts @@ -1,45 +1,9 @@ -import axios, { AxiosPromise, AxiosRequestConfig } from "axios"; import { - DEFAULT_CLIENT_APPLICATION, isError, SubsonicEnvelope, - t_and_s, - USER_AGENT, } from "."; // todo: rename http2 to http import { Http, http as http2 } from "../http"; -import { Credentials } from "../music_service"; -import { asURLSearchParams } from "../utils"; - -export const http = (base: string, credentials: Credentials): HTTP => ({ - get: async ( - path: string, - params: Partial<{ q: {}; config: AxiosRequestConfig | undefined }> - ) => - axios - .get(`${base}${path}`, { - params: asURLSearchParams({ - u: credentials.username, - v: "1.16.1", - c: DEFAULT_CLIENT_APPLICATION, - ...t_and_s(credentials.password), - f: "json", - ...(params.q || {}), - }), - headers: { - "User-Agent": USER_AGENT, - }, - ...(params.config || {}), - }) - .catch((e) => { - throw `Subsonic failed with: ${e}`; - }) - .then((response) => { - if (response.status != 200 && response.status != 206) { - throw `Subsonic failed with a ${response.status || "no!"} status`; - } else return response; - }), -}); export type HttpResponse = { data: any; @@ -47,24 +11,6 @@ export type HttpResponse = { headers: any; }; -export interface HTTP { - get( - path: string, - params: Partial<{ q: {}; config: AxiosRequestConfig | undefined }> - ): Promise; -} - -export const raw = (response: AxiosPromise) => - response - .catch((e) => { - throw `Subsonic failed with: ${e}`; - }) - .then((response) => { - if (response.status != 200 && response.status != 206) { - throw `Subsonic failed with a ${response.status || "no!"} status`; - } else return response; - }); - export const getRaw2 = (http: Http) => http({ method: "get" }) .catch((e) => { @@ -88,4 +34,4 @@ export const asJSON = (response: HttpResponse): T => { else return subsonicResponse as unknown as T; }; -export default http; + From 166a4b5ec25477b61fd4292042164e59e8f0b377 Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 23 Apr 2022 17:02:31 +1000 Subject: [PATCH 16/18] more tidy --- src/subsonic/index.ts | 14 ++++++-------- src/subsonic/library.ts | 9 ++++----- src/subsonic/subsonic_http.ts | 5 +++-- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/subsonic/index.ts b/src/subsonic/index.ts index c3e71ca..d6cd1c8 100644 --- a/src/subsonic/index.ts +++ b/src/subsonic/index.ts @@ -5,7 +5,7 @@ import axios from "axios"; import randomstring from "randomstring"; import _ from "underscore"; // todo: rename http2 to http -import { Http, http as http2 } from "../http"; +import { Http, http as http2, RequestParams } from "../http"; import { Credentials, @@ -17,7 +17,7 @@ import { import { b64Encode, b64Decode } from "../b64"; import { axiosImageFetcher, ImageFetcher } from "../images"; import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./library"; -import { getJSON as getJSON2 } from "./subsonic_http"; +import { getJSON as getJSON } from "./subsonic_http"; export const t = (password: string, s: string) => Md5.hashStr(`${password}${s}`); @@ -126,16 +126,14 @@ export class Subsonic implements MusicService { }, }); - getJSON = async ( - credentials: Credentials, - url: string, - params: {} = {} - ): Promise => getJSON2(http2(this.authenticatedSubsonicHttp(credentials), { url, params })); + GET = (query: Partial) => ({ + asJSON: () => getJSON(http2(this.subsonicHttp, query)), + }); generateToken = (credentials: Credentials) => pipe( TE.tryCatch( - () => getJSON2(http2(this.authenticatedSubsonicHttp(credentials), { url: "/rest/ping.view" })), + () => getJSON(http2(this.authenticatedSubsonicHttp(credentials), { url: "/rest/ping.view" })), (e) => new AuthFailure(e as string) ), TE.chain(({ type }) => diff --git a/src/subsonic/library.ts b/src/subsonic/library.ts index e1f2ce7..8866a70 100644 --- a/src/subsonic/library.ts +++ b/src/subsonic/library.ts @@ -38,9 +38,8 @@ import { import axios from "axios"; import { asURLSearchParams } from "../utils"; import { artistSummaryFromNDArtist, NDArtist } from "./navidrome"; -//todo: rename http2 -> http -import { Http, http as http2, RequestParams } from "../http"; -import { getRaw2, getJSON as getJSON2 } from "./subsonic_http"; +import { Http, http as newHttp, RequestParams } from "../http"; +import { getRaw2, getJSON } from "./subsonic_http"; type album = { id: string; @@ -288,8 +287,8 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { } GET = (query: Partial) => ({ - asRAW: () => getRaw2(http2(this.subsonicHttp, query)), - asJSON: () => getJSON2(http2(this.subsonicHttp, query)), + asRAW: () => getRaw2(newHttp(this.subsonicHttp, query)), + asJSON: () => getJSON(newHttp(this.subsonicHttp, query)), }); flavour = () => "subsonic"; diff --git a/src/subsonic/subsonic_http.ts b/src/subsonic/subsonic_http.ts index 4ed9a81..bb57e41 100644 --- a/src/subsonic/subsonic_http.ts +++ b/src/subsonic/subsonic_http.ts @@ -3,7 +3,7 @@ import { SubsonicEnvelope, } from "."; // todo: rename http2 to http -import { Http, http as http2 } from "../http"; +import { Http, http as newHttp } from "../http"; export type HttpResponse = { data: any; @@ -23,7 +23,7 @@ export const getRaw2 = (http: Http) => }); export const getJSON = async (http: Http): Promise => - getRaw2(http2(http, { params: { f: "json" } })).then(asJSON) as Promise; + getRaw2(newHttp(http, { params: { f: "json" } })).then(asJSON) as Promise; export const asJSON = (response: HttpResponse): T => { const subsonicResponse = (response.data as SubsonicEnvelope)[ @@ -35,3 +35,4 @@ export const asJSON = (response: HttpResponse): T => { }; + From 38f53168fa2b8e181df1643effc208bb6d3869b6 Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 8 Jul 2022 15:24:07 +1000 Subject: [PATCH 17/18] refactor --- src/http.ts | 79 +++++++++++------- src/server.ts | 26 +++--- src/subsonic/index.ts | 27 +++---- src/subsonic/library.ts | 30 ++----- src/subsonic/subsonic_http.ts | 53 ++++++++----- src/utils.ts | 18 ++++- tests/http.test.ts | 141 ++++++++++++++++++++++++++++++++- tests/subsonic/generic.test.ts | 4 +- tests/utils.test.ts | 31 +++++++- 9 files changed, 300 insertions(+), 109 deletions(-) diff --git a/src/http.ts b/src/http.ts index 0a6e561..0dd6154 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,8 +1,16 @@ -import { AxiosPromise, AxiosRequestConfig, ResponseType } from "axios"; +import { + AxiosPromise, + AxiosRequestConfig, + Method, + ResponseType, +} from "axios"; export interface Http { (config: AxiosRequestConfig): AxiosPromise; } +export interface Http2 extends Http { + with: (defaults: Partial) => Http2; +} export type RequestParams = { baseURL: string; @@ -10,32 +18,49 @@ export type RequestParams = { params: any; headers: any; responseType: ResponseType; + method: Method; }; -export const http = - (base: Http, defaults: Partial): Http => - (config: AxiosRequestConfig) => { - let toApply = { - ...defaults, - ...config, - }; - if (defaults.params) { - toApply = { - ...toApply, - params: { - ...defaults.params, - ...config.params, - }, - }; - } - if (defaults.headers) { - toApply = { - ...toApply, - headers: { - ...defaults.headers, - ...config.headers, - }, - }; - } - return base(toApply); +const wrap = (http2: Http2, defaults: Partial): Http2 => { + const f = ((config: AxiosRequestConfig) => http2(merge(defaults, config))) as Http2; + f.with = (defaults: Partial) => wrap(f, defaults); + return f; +}; + +export const http2From = (http: Http): Http2 => { + const f = ((config: AxiosRequestConfig) => http(config)) as Http2; + f.with = (defaults: Partial) => wrap(f, defaults); + return f; +} + +const merge = ( + defaults: Partial, + config: AxiosRequestConfig +) => { + let toApply = { + ...defaults, + ...config, }; + if (defaults.params) { + toApply = { + ...toApply, + params: { + ...defaults.params, + ...config.params, + }, + }; + } + if (defaults.headers) { + toApply = { + ...toApply, + headers: { + ...defaults.headers, + ...config.headers, + }, + }; + } + return toApply; +}; + +export const http = + (base: Http, defaults: Partial): Http => (config: AxiosRequestConfig) => base(merge(defaults, config)); diff --git a/src/server.ts b/src/server.ts index 34d4f55..f5d56c5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -33,13 +33,10 @@ import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n"; import { Icon, ICONS, festivals, features } from "./icon"; import _, { shuffle } from "underscore"; import morgan from "morgan"; -import { takeWithRepeats } from "./utils"; +import { mask, takeWithRepeats } from "./utils"; import { parse } from "./burn"; import { axiosImageFetcher, ImageFetcher } from "./images"; -import { - JWTSmapiLoginTokens, - SmapiAuthTokens, -} from "./smapi_auth"; +import { JWTSmapiLoginTokens, SmapiAuthTokens } from "./smapi_auth"; export const BONOB_ACCESS_TOKEN_HEADER = "bat"; @@ -377,23 +374,28 @@ function server( logger.info( `${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify( req.query - )}, headers=${JSON.stringify({ ...req.headers, "bnbt": "*****", "bnbk": "*****" })}` + )}, headers=${JSON.stringify(mask(req.headers, ["bnbt", "bnbk"]))}` ); const serviceToken = pipe( E.fromNullable("Missing bnbt header")(req.headers["bnbt"] as string), - E.chain(token => pipe( - E.fromNullable("Missing bnbk header")(req.headers["bnbk"] as string), - E.map(key => ({ token, key })) - )), + E.chain((token) => + pipe( + E.fromNullable("Missing bnbk header")(req.headers["bnbk"] as string), + E.map((key) => ({ token, key })) + ) + ), E.chain((auth) => pipe( smapiAuthTokens.verify(auth), E.mapLeft((_) => "Auth token failed to verify") ) ), - E.getOrElseW(() => undefined) - ) + E.getOrElseW((e: string) => { + logger.error(`Failed to get serviceToken for stream: ${e}`); + return undefined; + }) + ); if (!serviceToken) { return res.status(401).send(); diff --git a/src/subsonic/index.ts b/src/subsonic/index.ts index d6cd1c8..729e4b7 100644 --- a/src/subsonic/index.ts +++ b/src/subsonic/index.ts @@ -5,7 +5,7 @@ import axios from "axios"; import randomstring from "randomstring"; import _ from "underscore"; // todo: rename http2 to http -import { Http, http as http2, RequestParams } from "../http"; +import { Http2, http2From } from "../http"; import { Credentials, @@ -17,7 +17,7 @@ import { import { b64Encode, b64Decode } from "../b64"; import { axiosImageFetcher, ImageFetcher } from "../images"; import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./library"; -import { getJSON as getJSON } from "./subsonic_http"; +import { client } from "./subsonic_http"; export const t = (password: string, s: string) => Md5.hashStr(`${password}${s}`); @@ -101,7 +101,7 @@ export class Subsonic implements MusicService { // todo: why is this in here? externalImageFetcher: ImageFetcher; - subsonicHttp: Http; + subsonic: Http2; constructor( url: string, @@ -111,29 +111,22 @@ export class Subsonic implements MusicService { this.url = url; this.streamClientApplication = streamClientApplication; this.externalImageFetcher = externalImageFetcher; - this.subsonicHttp = http2(axios, { + this.subsonic = http2From(axios).with({ baseURL: this.url, params: { v: "1.16.1", c: DEFAULT_CLIENT_APPLICATION }, headers: { "User-Agent": "bonob" }, }); } - authenticatedSubsonicHttp = (credentials: Credentials) => - http2(this.subsonicHttp, { - params: { - u: credentials.username, - ...t_and_s(credentials.password), - }, - }); - - GET = (query: Partial) => ({ - asJSON: () => getJSON(http2(this.subsonicHttp, query)), - }); + asAuthParams = (credentials: Credentials) => ({ + u: credentials.username, + ...t_and_s(credentials.password), + }) generateToken = (credentials: Credentials) => pipe( TE.tryCatch( - () => getJSON(http2(this.authenticatedSubsonicHttp(credentials), { url: "/rest/ping.view" })), + () => client(this.subsonic.with({ params: this.asAuthParams(credentials) } ))({ method: 'get', url: "/rest/ping.view" }).asJSON(), (e) => new AuthFailure(e as string) ), TE.chain(({ type }) => @@ -168,7 +161,7 @@ export class Subsonic implements MusicService { ): Promise => { const subsonicGenericLibrary = new SubsonicGenericMusicLibrary( this.streamClientApplication, - this.authenticatedSubsonicHttp(credentials) + this.subsonic.with({ params: this.asAuthParams(credentials) } ) ); if (credentials.type == "navidrome") { return Promise.resolve( diff --git a/src/subsonic/library.ts b/src/subsonic/library.ts index 8866a70..8a929bc 100644 --- a/src/subsonic/library.ts +++ b/src/subsonic/library.ts @@ -38,8 +38,8 @@ import { import axios from "axios"; import { asURLSearchParams } from "../utils"; import { artistSummaryFromNDArtist, NDArtist } from "./navidrome"; -import { Http, http as newHttp, RequestParams } from "../http"; -import { getRaw2, getJSON } from "./subsonic_http"; +import { Http2, RequestParams } from "../http"; +import { client } from "./subsonic_http"; type album = { id: string; @@ -276,20 +276,17 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined => export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { streamClientApplication: StreamClientApplication; - subsonicHttp: Http; + subsonicHttp: Http2; constructor( streamClientApplication: StreamClientApplication, - subsonicHttp: Http + subsonicHttp: Http2 ) { this.streamClientApplication = streamClientApplication; this.subsonicHttp = subsonicHttp; } - GET = (query: Partial) => ({ - asRAW: () => getRaw2(newHttp(this.subsonicHttp, query)), - asJSON: () => getJSON(newHttp(this.subsonicHttp, query)), - }); + GET = (query: Partial) => client(this.subsonicHttp)({ method: 'get', ...query }); flavour = () => "subsonic"; @@ -390,7 +387,6 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { trackId: string; range: string | undefined; }) => - // todo: all these headers and stuff can be rolled into httpeee this.getTrack(trackId).then((track) => this.GET({ url: "/rest/stream", @@ -398,20 +394,10 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { id: trackId, c: this.streamClientApplication(track), }, - headers: pipe( - range, - O.fromNullable, - O.map((range) => ({ - // "User-Agent": USER_AGENT, - Range: range, - })), - O.getOrElse(() => ({ - // "User-Agent": USER_AGENT, - })) - ), + headers: range != undefined ? { Range: range } : {}, responseType: "stream", }) - .asRAW() + .asRaw() .then((res) => ({ status: res.status, headers: { @@ -717,7 +703,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { url: "/rest/getCoverArt", params: { id, size }, responseType: "arraybuffer", - }).asRAW(); + }).asRaw(); private getTrack = (id: string) => this.GET({ diff --git a/src/subsonic/subsonic_http.ts b/src/subsonic/subsonic_http.ts index bb57e41..390566e 100644 --- a/src/subsonic/subsonic_http.ts +++ b/src/subsonic/subsonic_http.ts @@ -1,9 +1,7 @@ -import { - isError, - SubsonicEnvelope, -} from "."; +import { AxiosResponse } from "axios"; +import { isError, SubsonicEnvelope } from "."; // todo: rename http2 to http -import { Http, http as newHttp } from "../http"; +import { Http2, RequestParams } from "../http"; export type HttpResponse = { data: any; @@ -11,21 +9,7 @@ export type HttpResponse = { headers: any; }; -export const getRaw2 = (http: Http) => - http({ method: "get" }) - .catch((e) => { - throw `Subsonic failed with: ${e}`; - }) - .then((response) => { - if (response.status != 200 && response.status != 206) { - throw `Subsonic failed with a ${response.status || "no!"} status`; - } else return response; - }); - -export const getJSON = async (http: Http): Promise => - getRaw2(newHttp(http, { params: { f: "json" } })).then(asJSON) as Promise; - -export const asJSON = (response: HttpResponse): T => { +const asJSON = (response: HttpResponse): T => { const subsonicResponse = (response.data as SubsonicEnvelope)[ "subsonic-response" ]; @@ -33,6 +17,35 @@ export const asJSON = (response: HttpResponse): T => { throw `Subsonic error:${subsonicResponse.error.message}`; else return subsonicResponse as unknown as T; }; +const throwUp = (error: any) => { + throw `Subsonic failed with: ${error}`; +}; +const verifyResponse = (response: AxiosResponse) => { + if (response.status != 200 && response.status != 206) { + throw `Subsonic failed with a ${response.status || "no!"} status`; + } else return response; +}; + +export interface SubsonicHttpResponse { + asRaw(): Promise>; + asJSON(): Promise; +} +export interface SubsonicHttp { + (query: Partial): SubsonicHttpResponse; +} +export const client = (http: Http2): SubsonicHttp => { + return (query: Partial): SubsonicHttpResponse => { + return { + asRaw: () => http(query).catch(throwUp).then(verifyResponse), + asJSON: () => + http + .with({ params: { f: "json" } })(query) + .catch(throwUp) + .then(verifyResponse) + .then(asJSON) as Promise, + }; + }; +}; diff --git a/src/utils.ts b/src/utils.ts index 3eba2c1..0315b0f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -22,11 +22,21 @@ export const asURLSearchParams = (q: any) => { return urlSearchParams; }; - -export function takeWithRepeats(things:T[], count: number) { +export function takeWithRepeats(things: T[], count: number) { const result = []; - for(let i = 0; i < count; i++) { - result.push(things[i % things.length]) + for (let i = 0; i < count; i++) { + result.push(things[i % things.length]); } return result; } + +export const mask = (thing: any, fields: string[]) => + fields.reduce( + (res: any, key: string) => { + if (Object.keys(res).includes(key)) { + res[key] = "****"; + } + return res; + }, + { ...thing } + ); diff --git a/tests/http.test.ts b/tests/http.test.ts index 85f69c6..5e2acf8 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -1,4 +1,4 @@ -import { http, } from "../src/http"; +import { http, http2From, } from "../src/http"; describe("http", () => { const mockAxios = jest.fn(); @@ -11,6 +11,7 @@ describe("http", () => { describe.each([ ["baseURL"], ["url"], + ["method"], ])('%s', (field) => { const getValue = (value: string) => { const thing = {} as any; @@ -136,3 +137,141 @@ describe("http", () => { }); }) }); + +describe("http2", () => { + const mockAxios = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + describe.each([ + ["baseURL"], + ["url"], + ["method"], + ])('%s', (field) => { + const fieldWithValue = (value: string) => { + const thing = {} as any; + thing[field] = value; + return thing; + }; + + const base = http2From(mockAxios).with(fieldWithValue('default')); + + describe("using default", () => { + it("should use the default", () => { + base({}) + expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('default')); + }); + }); + + describe("overriding", () => { + it("should use the override", () => { + base(fieldWithValue('override')) + expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('override')); + }); + }); + + describe("wrapping", () => { + const firstLayer = http2From(base).with(fieldWithValue('level1')); + const secondLayer = firstLayer.with(fieldWithValue('level2')); + + describe("when the outter call provides a value", () => { + it("should apply it", () => { + secondLayer(fieldWithValue('outter')) + expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('outter')); + }); + }); + + describe("when the outter call does not provide a value", () => { + it("should use the second layer", () => { + secondLayer({ }) + expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('level2')); + }); + }); + }); + }); + + describe("requestType", () => { + const base = http2From(mockAxios).with({ responseType: 'stream' }); + + describe("using default", () => { + it("should use the default", () => { + base({}) + expect(mockAxios).toHaveBeenCalledWith({ responseType: 'stream' }); + }); + }); + + describe("overriding", () => { + it("should use the override", () => { + base({ responseType: 'arraybuffer' }) + expect(mockAxios).toHaveBeenCalledWith({ responseType: 'arraybuffer' }); + }); + }); + + describe("wrapping", () => { + const firstLayer = base.with({ responseType: 'arraybuffer' }); + const secondLayer = firstLayer.with({ responseType: 'blob' }); + + describe("when the outter call provides a value", () => { + it("should apply it", () => { + secondLayer({ responseType: 'text' }) + expect(mockAxios).toHaveBeenCalledWith({ responseType: 'text' }); + }); + }); + + describe("when the outter call does not provide a value", () => { + it("should use the second layer", () => { + secondLayer({ }) + expect(mockAxios).toHaveBeenCalledWith({ responseType: 'blob' }); + }); + }); + }); + }); + + describe.each([ + ["params"], + ["headers"], + ])('%s', (field) => { + const fieldWithValues = (values: any) => { + const thing = {} as any; + thing[field] = values; + return thing; + } + const base = http2From(mockAxios).with(fieldWithValues({ a: 1, b: 2, c: 3, d: 4 })); + + describe("using default", () => { + it("should use the default", () => { + base({}); + expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 1, b: 2, c: 3, d: 4 })); + }); + }); + + describe("overriding", () => { + it("should use the override", () => { + base(fieldWithValues({ b: 22, e: 5 })); + expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 1, b: 22, c: 3, d: 4, e: 5 })); + }); + }); + + describe("wrapping", () => { + const firstLayer = base.with(fieldWithValues({ b: 22 })); + const secondLayer = firstLayer.with(fieldWithValues({ c: 33 })); + + describe("when the outter call provides a value", () => { + it("should apply it", () => { + secondLayer(fieldWithValues({ a: 11, e: 5 })); + expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 11, b: 22, c: 33, d: 4, e: 5 })); + }); + }); + + describe("when the outter call does not provide a value", () => { + it("should use the second layer", () => { + secondLayer({ }); + expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 1, b: 22, c: 33, d: 4 })); + }); + }); + }); + }) +}); diff --git a/tests/subsonic/generic.test.ts b/tests/subsonic/generic.test.ts index e2039d7..0cc9dd9 100644 --- a/tests/subsonic/generic.test.ts +++ b/tests/subsonic/generic.test.ts @@ -46,7 +46,7 @@ import { import { EMPTY, error, FAILURE, subsonicOK, ok } from "../subsonic.test"; import { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; import { b64Encode } from "../../src/b64"; -import { http as http2 } from "../../src/http"; +import { http2From } from "../../src/http"; const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => pipe( @@ -511,7 +511,7 @@ describe("SubsonicGenericMusicLibrary", () => { const generic = new SubsonicGenericMusicLibrary( streamClientApplication, // todo: all this stuff doesnt need to be defaulted in here. - http2(mockAxios, { + http2From(mockAxios).with({ baseURL, params: authParams, headers diff --git a/tests/utils.test.ts b/tests/utils.test.ts index f494703..3d1517a 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,4 +1,4 @@ -import { asURLSearchParams, takeWithRepeats } from "../src/utils"; +import { asURLSearchParams, mask, takeWithRepeats } from "../src/utils"; describe("asURLSearchParams", () => { describe("empty q", () => { @@ -46,8 +46,6 @@ describe("asURLSearchParams", () => { }); }); - - describe("takeWithRepeat", () => { describe("when there is nothing in the input", () => { it("should return an array of undefineds", () => { @@ -77,7 +75,32 @@ describe("takeWithRepeat", () => { describe("when there more than the amount required", () => { it("should return the first n items", () => { expect(takeWithRepeats(["a", "b", "c"], 2)).toEqual(["a", "b"]); - expect(takeWithRepeats(["a", undefined, "c"], 2)).toEqual(["a", undefined]); + expect(takeWithRepeats(["a", undefined, "c"], 2)).toEqual([ + "a", + undefined, + ]); }); }); }); + +describe("mask", () => { + it.each([ + [{}, ["a", "b"], {}], + [{ foo: "bar" }, ["a", "b"], { foo: "bar" }], + [{ a: 1 }, ["a", "b"], { a: "****" }], + [{ a: 1, b: "dog" }, ["a", "b"], { a: "****", b: "****" }], + [ + { a: 1, b: "dog", foo: "bar" }, + ["a", "b"], + { a: "****", b: "****", foo: "bar" }, + ], + ])( + "masking of %s, keys = %s, should result in %s", + (original: any, keys: string[], expected: any) => { + const copyOfOrig = JSON.parse(JSON.stringify(original)); + const masked = mask(original, keys); + expect(masked).toEqual(expected); + expect(original).toEqual(copyOfOrig); + } + ); +}); From add87e5df9b91a4d5cc0ce93b71c27f1116c2902 Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 29 Jul 2022 13:45:57 +1000 Subject: [PATCH 18/18] refactor --- src/http.ts | 9 +-- src/subsonic/{library.ts => generic.ts} | 64 -------------------- src/subsonic/index.ts | 10 ++-- src/subsonic/navidrome.ts | 77 ++++++++++++++++++++++++- tests/builders.ts | 2 +- tests/server.test.ts | 2 +- tests/smapi.test.ts | 2 +- tests/subsonic/generic.test.ts | 2 +- tests/subsonic/navidrome.test.ts | 2 +- 9 files changed, 90 insertions(+), 80 deletions(-) rename src/subsonic/{library.ts => generic.ts} (91%) diff --git a/src/http.ts b/src/http.ts index 0dd6154..8464492 100644 --- a/src/http.ts +++ b/src/http.ts @@ -5,11 +5,12 @@ import { ResponseType, } from "axios"; +// todo: do i need this anymore? export interface Http { (config: AxiosRequestConfig): AxiosPromise; } export interface Http2 extends Http { - with: (defaults: Partial) => Http2; + with: (params: Partial) => Http2; } export type RequestParams = { @@ -21,9 +22,9 @@ export type RequestParams = { method: Method; }; -const wrap = (http2: Http2, defaults: Partial): Http2 => { - const f = ((config: AxiosRequestConfig) => http2(merge(defaults, config))) as Http2; - f.with = (defaults: Partial) => wrap(f, defaults); +const wrap = (http2: Http2, params: Partial): Http2 => { + const f = ((config: AxiosRequestConfig) => http2(merge(params, config))) as Http2; + f.with = (params: Partial) => wrap(f, params); return f; }; diff --git a/src/subsonic/library.ts b/src/subsonic/generic.ts similarity index 91% rename from src/subsonic/library.ts rename to src/subsonic/generic.ts index 8a929bc..a1f2d2e 100644 --- a/src/subsonic/library.ts +++ b/src/subsonic/generic.ts @@ -768,67 +768,3 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { total: albums.length == 500 ? total : (q._index || 0) + albums.length, })); } - -export const navidromeMusicLibrary = ( - url: string, - subsonicLibrary: SubsonicMusicLibrary, - subsonicCredentials: SubsonicCredentials -): SubsonicMusicLibrary => ({ - ...subsonicLibrary, - flavour: () => "navidrome", - bearerToken: ( - credentials: Credentials - ): TE.TaskEither => - pipe( - TE.tryCatch( - () => - // todo: not hardcode axios in here - axios({ - method: "post", - baseURL: url, - url: `/auth/login`, - data: _.pick(credentials, "username", "password"), - }), - () => new AuthFailure("Failed to get bearerToken") - ), - 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, - }; - } - - const x: Promise> = axios - .get(`${url}/api/artist`, { - params: asURLSearchParams(params), - headers: { - "User-Agent": USER_AGENT, - "x-nd-authorization": `Bearer ${subsonicCredentials.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"), - })); - - return x; - }, -}); diff --git a/src/subsonic/index.ts b/src/subsonic/index.ts index 729e4b7..c5ffd5a 100644 --- a/src/subsonic/index.ts +++ b/src/subsonic/index.ts @@ -16,7 +16,7 @@ import { } from "../music_service"; import { b64Encode, b64Decode } from "../b64"; import { axiosImageFetcher, ImageFetcher } from "../images"; -import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./library"; +import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./generic"; import { client } from "./subsonic_http"; export const t = (password: string, s: string) => @@ -101,7 +101,7 @@ export class Subsonic implements MusicService { // todo: why is this in here? externalImageFetcher: ImageFetcher; - subsonic: Http2; + subsonicHttp: Http2; constructor( url: string, @@ -111,7 +111,7 @@ export class Subsonic implements MusicService { this.url = url; this.streamClientApplication = streamClientApplication; this.externalImageFetcher = externalImageFetcher; - this.subsonic = http2From(axios).with({ + this.subsonicHttp = http2From(axios).with({ baseURL: this.url, params: { v: "1.16.1", c: DEFAULT_CLIENT_APPLICATION }, headers: { "User-Agent": "bonob" }, @@ -126,7 +126,7 @@ export class Subsonic implements MusicService { generateToken = (credentials: Credentials) => pipe( TE.tryCatch( - () => client(this.subsonic.with({ params: this.asAuthParams(credentials) } ))({ method: 'get', url: "/rest/ping.view" }).asJSON(), + () => client(this.subsonicHttp.with({ params: this.asAuthParams(credentials) } ))({ method: 'get', url: "/rest/ping.view" }).asJSON(), (e) => new AuthFailure(e as string) ), TE.chain(({ type }) => @@ -161,7 +161,7 @@ export class Subsonic implements MusicService { ): Promise => { const subsonicGenericLibrary = new SubsonicGenericMusicLibrary( this.streamClientApplication, - this.subsonic.with({ params: this.asAuthParams(credentials) } ) + this.subsonicHttp.with({ params: this.asAuthParams(credentials) } ) ); if (credentials.type == "navidrome") { return Promise.resolve( diff --git a/src/subsonic/navidrome.ts b/src/subsonic/navidrome.ts index 6f4c75b..c7b104e 100644 --- a/src/subsonic/navidrome.ts +++ b/src/subsonic/navidrome.ts @@ -1,5 +1,14 @@ -import { ArtistSummary, Sortable } from "../music_service"; -import { artistImageURN } from "./library"; +import { option as O, taskEither as TE } from "fp-ts"; +import * as A from "fp-ts/Array"; +import { pipe } from "fp-ts/lib/function"; +import { ordString } from "fp-ts/lib/Ord"; +import { inject } from "underscore"; +import _ from "underscore"; +import axios from "axios"; + +import { SubsonicCredentials, SubsonicMusicLibrary } from "."; +import { ArtistQuery, ArtistSummary, AuthFailure, Credentials, Result, Sortable } from "../music_service"; +import { artistImageURN } from "./generic"; export type NDArtist = { id: string; @@ -20,3 +29,67 @@ export const artistSummaryFromNDArtist = ( }), }); + +export const navidromeMusicLibrary = ( + url: string, + subsonicLibrary: SubsonicMusicLibrary, + subsonicCredentials: SubsonicCredentials +): SubsonicMusicLibrary => ({ + ...subsonicLibrary, + flavour: () => "navidrome", + bearerToken: ( + credentials: Credentials + ): TE.TaskEither => + pipe( + TE.tryCatch( + () => + // todo: not hardcode axios in here + axios({ + method: "post", + baseURL: url, + url: `/auth/login`, + data: _.pick(credentials, "username", "password"), + }), + () => new AuthFailure("Failed to get bearerToken") + ), + 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, + }; + } + + const x: Promise> = axios + .get(`${url}/api/artist`, { + params: asURLSearchParams(params), + headers: { + "User-Agent": USER_AGENT, + "x-nd-authorization": `Bearer ${subsonicCredentials.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"), + })); + + return x; + }, +}); diff --git a/tests/builders.ts b/tests/builders.ts index 37e2d6a..d3695de 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -17,7 +17,7 @@ import { } from "../src/music_service"; import { b64Encode } from "../src/b64"; -import { artistImageURN } from "../src/subsonic/library"; +import { artistImageURN } from "../src/subsonic/generic"; const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max)); const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`; diff --git a/tests/server.test.ts b/tests/server.test.ts index 27dd4e7..9f38827 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -167,7 +167,7 @@ describe("RangeBytesFromFilter", () => { describe("server", () => { - jest.setTimeout(Number.parseInt(process.env["JEST_TIMEOUT"] || "2000")); + jest.setTimeout(Number.parseInt(process.env["JEST_TIMEOUT"] || "5000")); beforeEach(() => { jest.clearAllMocks(); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 8111c61..4ccfb60 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -91,7 +91,7 @@ describe("rating to and from ints", () => { }); describe("service config", () => { - jest.setTimeout(Number.parseInt(process.env["JEST_TIMEOUT"] || "2000")); + jest.setTimeout(Number.parseInt(process.env["JEST_TIMEOUT"] || "5000")); const bonobWithNoContextPath = url("http://localhost:1234"); const bonobWithContextPath = url("http://localhost:5678/some-context-path"); diff --git a/tests/subsonic/generic.test.ts b/tests/subsonic/generic.test.ts index 0cc9dd9..0fbb936 100644 --- a/tests/subsonic/generic.test.ts +++ b/tests/subsonic/generic.test.ts @@ -42,7 +42,7 @@ import { isValidImage, song, SubsonicGenericMusicLibrary, -} from "../../src/subsonic/library"; +} from "../../src/subsonic/generic"; import { EMPTY, error, FAILURE, subsonicOK, ok } from "../subsonic.test"; import { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; import { b64Encode } from "../../src/b64"; diff --git a/tests/subsonic/navidrome.test.ts b/tests/subsonic/navidrome.test.ts index 6aca01c..f94d685 100644 --- a/tests/subsonic/navidrome.test.ts +++ b/tests/subsonic/navidrome.test.ts @@ -1,6 +1,6 @@ import { v4 as uuid } from "uuid"; import { DODGY_IMAGE_NAME } from "../../src/subsonic"; -import { artistImageURN } from "../../src/subsonic/library"; +import { artistImageURN } from "../../src/subsonic/generic"; import { artistSummaryFromNDArtist } from "../../src/subsonic/navidrome";