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" diff --git a/src/app.ts b/src/app.ts index 970c18d..b7b8304 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; @@ -32,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/http.ts b/src/http.ts new file mode 100644 index 0000000..8464492 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,67 @@ +import { + AxiosPromise, + AxiosRequestConfig, + Method, + ResponseType, +} from "axios"; + +// todo: do i need this anymore? +export interface Http { + (config: AxiosRequestConfig): AxiosPromise; +} +export interface Http2 extends Http { + with: (params: Partial) => Http2; +} + +export type RequestParams = { + baseURL: string; + url: string; + params: any; + headers: any; + responseType: ResponseType; + method: Method; +}; + +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; +}; + +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/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/music_service.ts b/src/music_service.ts index d7a5065..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; @@ -65,8 +71,8 @@ export type Track = { }; export type Paging = { - _index: number; - _count: number; + _index: number | undefined; + _count: number | undefined; }; export type Result = { @@ -74,9 +80,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 +145,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 +163,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/server.ts b/src/server.ts index fc6bd65..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 "./subsonic"; -import { - JWTSmapiLoginTokens, - SmapiAuthTokens, -} from "./smapi_auth"; +import { axiosImageFetcher, ImageFetcher } from "./images"; +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/smapi.ts b/src/smapi.ts index 9d5686b..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,6 +367,54 @@ export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({ albumArtURI: defaultArtistArtURI(bonobUrl, artist).href(), }); +export const scrollIndicesFrom = (things: Sortable[]) => { + 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 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; + } + } + 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 +756,7 @@ function bindSmapiSoapServiceToExpress( title: lang("artists"), albumArtURI: iconArtURI(bonobUrl, "artists").href(), itemType: "container", + canScroll: true, }, { id: "albums", @@ -945,6 +995,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: undefined })) + .then((artists) => ({ + getScrollIndicesResult: scrollIndicesFrom(artists.results) + })) + } + default: + throw `Unsupported getScrollIndices id=${id}`; + } + }, createContainer: async ( { title, seedId }: { title: string; seedId: string | undefined }, _, diff --git a/src/subsonic.ts b/src/subsonic.ts deleted file mode 100644 index 2bfa3d9..0000000 --- a/src/subsonic.ts +++ /dev/null @@ -1,978 +0,0 @@ -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 { 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, - CoverArt, - Rating, - AlbumQueryType, - Artist, - AuthFailure, -} 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"; -import { b64Encode, b64Decode } from "./b64"; -import logger from "./logger"; -import { assertSystem, BUrn } from "./burn"; -import { artist } from "./smapi"; - -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}`); - -export const t_and_s = (password: string) => { - const s = randomstring.generate(); - return { - t: t(password, s), - s, - }; -}; - -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 = { - 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 & { - 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; -}; - -type GetStarredResponse = { - starred2: { - song: song[]; - album: album[]; - }; -}; - -export type PingResponse = { - status: string; - version: string; - type: string; - serverVersion: string; -}; - -type Search3Response = SubsonicResponse & { - searchResult3: { - artist: artist[]; - album: album[]; - song: song[]; - }; -}; - -export function isError( - subsonicResponse: SubsonicResponse -): subsonicResponse is SubsonicError { - return (subsonicResponse as SubsonicError).error !== 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 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: StreamClientApplication = (_: Track) => - DEFAULT_CLIENT_APPLICATION; - -export function appendMimeTypeToClientFor(mimeTypes: string[]) { - return (track: Track) => - 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; -}; - -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", - byGenre: "byGenre", - random: "random", - recentlyPlayed: "recent", - mostPlayed: "frequent", - recentlyAdded: "newest", - favourited: "starred", - starred: "highest", -}; - -const artistIsInLibrary = (artistId: string | undefined) => - artistId != undefined && artistId != "-1"; - -type SubsonicCredentials = Credentials & { - type: string; - bearer: string | undefined; -}; - -export const asToken = (credentials: SubsonicCredentials) => - b64Encode(JSON.stringify(credentials)); -export const parseToken = (token: string): SubsonicCredentials => - JSON.parse(b64Decode(token)); - -interface SubsonicMusicLibrary extends MusicLibrary { - flavour(): string; - bearerToken( - credentials: Credentials - ): TE.TaskEither; -} - -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 }), - () => 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") - .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, - }), - })) - ); - - getArtistInfo = ( - credentials: Credentials, - id: string - ): Promise<{ - similarArtist: (ArtistSummary & { inLibrary: boolean })[]; - images: { - s: string | undefined; - m: string | undefined; - l: string | undefined; - }; - }> => - this.getJSON(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, - }), - })), - })); - - getAlbum = (credentials: Credentials, id: string): Promise => - this.getJSON(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), - })); - - getArtist = ( - credentials: Credentials, - id: string - ): Promise< - IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] } - > => - this.getJSON(credentials, "/rest/getArtist", { - id, - }) - .then((it) => it.artist) - .then((it) => ({ - id: it.id, - name: it.name, - artistImageUrl: it.artistImageUrl, - albums: this.toAlbumSummary(it.album || []), - })); - - getArtistWithInfo = (credentials: Credentials, id: string) => - Promise.all([ - this.getArtist(credentials, id), - this.getArtistInfo(credentials, 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, - })); - - getCoverArt = (credentials: Credentials, id: string, size?: number) => - this.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, - }) - .then((it) => it.song) - .then((song) => - this.getAlbum(credentials, 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[] => - 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), - })); - - 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 || [], - })); - - getAlbumList2 = (credentials: Credentials, q: AlbumQuery) => - Promise.all([ - this.getArtists(credentials).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, - }) - .then((response) => response.albumList2.album || []) - .then(this.toAlbumSummary), - ]).then(([total, albums]) => ({ - results: albums.slice(0, q._count), - total: albums.length == 500 ? total : q._index + albums.length, - })); - - // getStarred2 = (credentials: Credentials): Promise<{ albums: Album[] }> => - // this.getJSON(credentials, "/rest/getStarred2") - // .then((it) => it.starred2) - // .then((it) => ({ - // albums: it.album.map(asAlbum), - // })); - - login = async (token: string) => this.libraryFor(parseToken(token)); - - private libraryFor = ( - credentials: Credentials & { type: string } - ): 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, - 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)) - ) - ) - ) - ), - }; - - 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) - ), - }); - } else { - return Promise.resolve(genericSubsonic); - } - }; -} diff --git a/src/subsonic/generic.ts b/src/subsonic/generic.ts new file mode 100644 index 0000000..a1f2d2e --- /dev/null +++ b/src/subsonic/generic.ts @@ -0,0 +1,770 @@ +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 logger from "../logger"; +import { b64Decode, b64Encode } from "../b64"; +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 { + DODGY_IMAGE_NAME, + StreamClientApplication, + SubsonicCredentials, + SubsonicMusicLibrary, + SubsonicResponse, + USER_AGENT, +} from "."; +import axios from "axios"; +import { asURLSearchParams } from "../utils"; +import { artistSummaryFromNDArtist, NDArtist } from "./navidrome"; +import { Http2, RequestParams } from "../http"; +import { client } from "./subsonic_http"; + +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[]; +}; + +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 { + streamClientApplication: StreamClientApplication; + subsonicHttp: Http2; + + constructor( + streamClientApplication: StreamClientApplication, + subsonicHttp: Http2 + ) { + this.streamClientApplication = streamClientApplication; + this.subsonicHttp = subsonicHttp; + } + + GET = (query: Partial) => client(this.subsonicHttp)({ method: 'get', ...query }); + + flavour = () => "subsonic"; + + bearerToken = (_: Credentials): TE.TaskEither => + TE.right(undefined); + + artists = async (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.GET({ + url: "/rest/getGenres", + }) + .asJSON() + .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.GET({ + url: "/rest/getAlbum", + params: { + id: albumId, + }, + }) + .asJSON() + .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.GET({ + url: `/rest/${rating.love ? "star" : "unstar"}`, + params: { + id: trackId, + }, + }).asJSON() + ); + } + if (track.rating.stars != rating.stars) { + thingsToUpdate.push( + this.GET({ + url: `/rest/setRating`, + params: { + id: trackId, + rating: rating.stars, + }, + }).asJSON() + ); + } + return Promise.all(thingsToUpdate); + }) + .then(() => true) + .catch(() => false); + + stream = async ({ + trackId, + range, + }: { + trackId: string; + range: string | undefined; + }) => + this.getTrack(trackId).then((track) => + this.GET({ + url: "/rest/stream", + params: { + id: trackId, + c: this.streamClientApplication(track), + }, + headers: range != undefined ? { Range: range } : {}, + responseType: "stream", + }) + .asRaw() + .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(it, size)) + .then((res) => ({ + contentType: res.headers["content-type"], + data: Buffer.from(res.data, "binary"), + })) + .catch((e) => { + logger.error( + `Failed getting coverArt for '${format(coverArtURN)}': ${e}` + ); + return undefined; + }); + + scrobble = async (id: string) => + this.GET({ + url: `/rest/scrobble`, + params: { + id, + submission: true, + }, + }) + .asJSON() + .then((_) => true) + .catch(() => false); + + nowPlaying = async (id: string) => + this.GET({ + url: `/rest/scrobble`, + params: { + id, + submission: false, + }, + }) + .asJSON() + .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.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.GET({ + url: "/rest/getPlaylist", + params: { + id, + }, + }) + .asJSON() + .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.GET({ + url: "/rest/createPlaylist", + params: { + name, + }, + }) + .asJSON() + .then((it) => it.playlist) + .then((it) => ({ id: it.id, name: it.name })); + + deletePlaylist = async (id: string) => + this.GET({ + url: "/rest/deletePlaylist", + params: { + id, + }, + }) + .asJSON() + .then((_) => true); + + addToPlaylist = async (playlistId: string, trackId: string) => + this.GET({ + url: "/rest/updatePlaylist", + params: { + playlistId, + songIdToAdd: trackId, + }, + }) + .asJSON() + .then((_) => true); + + removeFromPlaylist = async (playlistId: string, indicies: number[]) => + this.GET({ + url: "/rest/updatePlaylist", + params: { + playlistId, + songIndexToRemove: indicies, + }, + }) + .asJSON() + .then((_) => true); + + similarSongs = async (id: string) => + this.GET({ + url: "/rest/getSimilarSongs2", + params: { id, count: 50 }, + }) + .asJSON() + .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.GET({ + url: "/rest/getTopSongs", + params: { + artist: name, + count: 50, + }, + }) + .asJSON() + .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.GET({ url: "/rest/getArtists" }) + .asJSON() + .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.GET({ + url: "/rest/getArtistInfo2", + params: { + id, + count: 50, + includeNotPresent: true, + }, + }) + .asJSON() + .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.GET({ url: "/rest/getAlbum", params: { id } }) + .asJSON() + .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.GET({ + url: "/rest/getArtist", + params: { + id, + }, + }) + .asJSON() + .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 = (id: string, size?: number) => + this.GET({ + url: "/rest/getCoverArt", + params: { id, size }, + responseType: "arraybuffer", + }).asRaw(); + + private getTrack = (id: string) => + this.GET({ + url: "/rest/getSong", + params: { + id, + }, + }) + .asJSON() + .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.GET({ + url: "/rest/search3", + params: { + artistCount: 0, + albumCount: 0, + songCount: 0, + ...q, + }, + }) + .asJSON() + .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.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]) => ({ + results: albums.slice(0, q._count), + total: albums.length == 500 ? total : (q._index || 0) + albums.length, + })); +} diff --git a/src/subsonic/index.ts b/src/subsonic/index.ts new file mode 100644 index 0000000..c5ffd5a --- /dev/null +++ b/src/subsonic/index.ts @@ -0,0 +1,176 @@ +import { taskEither as TE } from "fp-ts"; +import { pipe } from "fp-ts/lib/function"; +import { Md5 } from "ts-md5/dist/md5"; +import axios from "axios"; +import randomstring from "randomstring"; +import _ from "underscore"; +// todo: rename http2 to http +import { Http2, http2From } from "../http"; + +import { + Credentials, + MusicService, + MusicLibrary, + Track, + AuthFailure, +} from "../music_service"; +import { b64Encode, b64Decode } from "../b64"; +import { axiosImageFetcher, ImageFetcher } from "../images"; +import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./generic"; +import { client } from "./subsonic_http"; + +export const t = (password: string, s: string) => + Md5.hashStr(`${password}${s}`); + +export const t_and_s = (password: string) => { + const s = randomstring.generate(); + return { + t: t(password, s), + s, + }; +}; + +// todo: this is an ND thing +export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png"; + +export type SubsonicEnvelope = { + "subsonic-response": SubsonicResponse; +}; + +export type SubsonicResponse = { + status: string; +}; + +export type SubsonicError = SubsonicResponse & { + error: { + code: string; + message: string; + }; +}; + +export type PingResponse = { + status: string; + version: string; + type: string; + serverVersion: string; +}; + +export function isError( + subsonicResponse: SubsonicResponse +): subsonicResponse is SubsonicError { + return (subsonicResponse as SubsonicError).error !== undefined; +} + +// todo: is this a good name? +export type StreamClientApplication = (track: Track) => string; + +export const DEFAULT_CLIENT_APPLICATION = "bonob"; +export const USER_AGENT = "bonob"; + +export const DEFAULT: StreamClientApplication = (_: Track) => + DEFAULT_CLIENT_APPLICATION; + +export function appendMimeTypeToClientFor(mimeTypes: string[]) { + return (track: Track) => + mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob"; +} + +export type SubsonicCredentials = Credentials & { + type: string; + bearer: string | undefined; +}; + +export const asToken = (credentials: SubsonicCredentials) => + b64Encode(JSON.stringify(credentials)); + +export const parseToken = (token: string): SubsonicCredentials => + JSON.parse(b64Decode(token)); + +export interface SubsonicMusicLibrary extends MusicLibrary { + flavour(): string; + bearerToken( + credentials: Credentials + ): TE.TaskEither; +} + +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; + + subsonicHttp: Http2; + + constructor( + url: string, + streamClientApplication: StreamClientApplication = DEFAULT, + externalImageFetcher: ImageFetcher = axiosImageFetcher + ) { + this.url = url; + this.streamClientApplication = streamClientApplication; + this.externalImageFetcher = externalImageFetcher; + this.subsonicHttp = http2From(axios).with({ + baseURL: this.url, + params: { v: "1.16.1", c: DEFAULT_CLIENT_APPLICATION }, + headers: { "User-Agent": "bonob" }, + }); + } + + asAuthParams = (credentials: Credentials) => ({ + u: credentials.username, + ...t_and_s(credentials.password), + }) + + generateToken = (credentials: Credentials) => + pipe( + TE.tryCatch( + () => client(this.subsonicHttp.with({ params: this.asAuthParams(credentials) } ))({ method: 'get', url: "/rest/ping.view" }).asJSON(), + (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)); + + private libraryFor = ( + credentials: SubsonicCredentials + ): Promise => { + const subsonicGenericLibrary = new SubsonicGenericMusicLibrary( + this.streamClientApplication, + this.subsonicHttp.with({ params: this.asAuthParams(credentials) } ) + ); + if (credentials.type == "navidrome") { + return Promise.resolve( + navidromeMusicLibrary(this.url, subsonicGenericLibrary, credentials) + ); + } else { + return Promise.resolve(subsonicGenericLibrary); + } + }; +} + +export default Subsonic; diff --git a/src/subsonic/navidrome.ts b/src/subsonic/navidrome.ts new file mode 100644 index 0000000..c7b104e --- /dev/null +++ b/src/subsonic/navidrome.ts @@ -0,0 +1,95 @@ +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; + 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, + }), +}); + + +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/subsonic_http.ts b/src/subsonic/subsonic_http.ts new file mode 100644 index 0000000..390566e --- /dev/null +++ b/src/subsonic/subsonic_http.ts @@ -0,0 +1,51 @@ +import { AxiosResponse } from "axios"; +import { isError, SubsonicEnvelope } from "."; +// todo: rename http2 to http +import { Http2, RequestParams } from "../http"; + +export type HttpResponse = { + data: any; + status: number; + headers: any; +}; + +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; +}; +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 c886afc..0315b0f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,42 @@ -export function takeWithRepeats(things:T[], count: number) { +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", + "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", +}; + +// todo: move this +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++) { - result.push(things[i % things.length]) + for (let i = 0; i < count; i++) { + result.push(things[i % things.length]); } return result; -} \ No newline at end of file +} + +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/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/http.test.ts b/tests/http.test.ts new file mode 100644 index 0000000..5e2acf8 --- /dev/null +++ b/tests/http.test.ts @@ -0,0 +1,277 @@ +import { http, http2From, } from "../src/http"; + +describe("http", () => { + const mockAxios = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + describe.each([ + ["baseURL"], + ["url"], + ["method"], + ])('%s', (field) => { + const getValue = (value: string) => { + const thing = {} as any; + thing[field] = value; + return thing; + }; + + const base = http(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 = http(base, getValue('level1')); + const secondLayer = http(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 = http(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 = http(base, { responseType: 'arraybuffer' }); + const secondLayer = http(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 = http(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 = http(base, getValues({ b: 22 })); + const secondLayer = http(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 })); + }); + }); + }); + }) +}); + +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/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/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/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 fbbae83..4ccfb60 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"; @@ -90,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"); @@ -860,6 +861,54 @@ describe("defaultArtistArtURI", () => { }); }); +describe("scrollIndicesFrom", () => { + describe("artists", () => { + 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") + }); + }) + }); +}); + describe("wsdl api", () => { const musicService = { generateToken: jest.fn(), @@ -1410,6 +1459,7 @@ describe("wsdl api", () => { title: "Artists", albumArtURI: iconArtURI(bonobUrl, "artists").href(), itemType: "container", + canScroll: true, }, { id: "albums", @@ -1498,6 +1548,7 @@ describe("wsdl api", () => { title: "Artiesten", albumArtURI: iconArtURI(bonobUrl, "artists").href(), itemType: "container", + canScroll: true, }, { id: "albums", @@ -3111,6 +3162,51 @@ 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" }); + + 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, + httpClient: supersoap(server), + }); + setupAuthenticatedRequest(ws); + musicLibrary.artists.mockResolvedValue({ + results: artistsWithSortName, + total: 6 + }); + }); + + it("should return paging information", async () => { + const root = await ws.getScrollIndicesAsync({ + id: `artists`, + }); + + expect(root[0]).toEqual({ + getScrollIndicesResult: scrollIndicesFrom(artistsWithSortName) + }); + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); + expect(musicLibrary.artists).toHaveBeenCalledWith({ _index: 0, _count: undefined }); + }); + }); + }); + describe("createContainer", () => { let ws: Client; diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 625f1bf..d0dcd76 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -1,67 +1,31 @@ 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"; +import { taskEither as TE, task as T, either as E } from "fp-ts"; import { - isValidImage, Subsonic, t, - DODGY_IMAGE_NAME, - asGenre, appendMimeTypeToClientFor, - asURLSearchParams, - cachingImageFetcher, - asTrack, - artistImageURN, - images, - song, PingResponse, parseToken, asToken, + SubsonicCredentials, } from "../src/subsonic"; import axios from "axios"; jest.mock("axios"); -import sharp from "sharp"; -jest.mock("sharp"); - import randomstring from "randomstring"; jest.mock("randomstring"); import { - Album, - Artist, - albumToAlbumSummary, - asArtistAlbumPairs, - Track, - AlbumSummary, - artistToArtistSummary, - AlbumQuery, - PlaylistSummary, - Playlist, - SimilarArtist, - Rating, - Credentials, 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"; describe("t", () => { it("should be an md5 of the password and the salt", () => { @@ -71,26 +35,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", () => { @@ -125,261 +69,12 @@ 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); - }); - }); -}); - -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) => ({ +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 getStarredJson = ({ albums }: { albums: Album[] }) => subsonicOK({starred2: { -// album: albums.map(it => asAlbumJson({ id: it.artistId, name: it.artistName }, it, [])), -// song: [], -// }}) - -const subsonicOK = (body: any = {}) => ({ +export const subsonicOK = (body: any = {}) => ({ "subsonic-response": { status: "ok", version: "1.16.1", @@ -389,151 +84,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", @@ -543,7 +95,7 @@ const error = (code: string, message: string) => ({ }, }); -const EMPTY = { +export const EMPTY = { "subsonic-response": { status: "ok", version: "1.16.1", @@ -552,7 +104,7 @@ const EMPTY = { }, }; -const FAILURE = { +export const FAILURE = { "subsonic-response": { status: "failed", version: "1.16.1", @@ -562,8 +114,6 @@ const FAILURE = { }, }; - - const pingJson = (pingResponse: Partial = {}) => ({ "subsonic-response": { status: "ok", @@ -576,119 +126,11 @@ const pingJson = (pingResponse: Partial = {}) => ({ const PING_OK = pingJson({ status: "ok" }); -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 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"; @@ -700,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); }); @@ -731,19 +169,22 @@ 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)) - describe("generateToken", () => { 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, @@ -756,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, @@ -777,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, }); }); @@ -788,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, @@ -802,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), - headers, - }); - expect(axios.post).toHaveBeenCalledWith(`${url}/auth/login`, { - username, - password, + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/ping.view`, + params: authParamsPlusJson, + headers, + }); + expect(mockAxios).toHaveBeenCalledWith({ + method: 'post', + baseURL, + url: `/auth/login`, + data: { + username, + password, + } }); }); }); @@ -816,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"), }); @@ -832,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) @@ -848,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, }); }); @@ -859,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) @@ -876,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), - headers, - }); - expect(axios.post).toHaveBeenCalledWith(`${url}/auth/login`, { - username, - password, + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/ping.view`, + params: authParamsPlusJson, + headers, + }); + expect(mockAxios).toHaveBeenCalledWith({ + method: 'post', + baseURL, + url: `/auth/login`, + data: { + username, + password, + } }); }); }); @@ -890,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"), }); @@ -927,3840 +395,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 there are indexes, but no artists", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - subsonicOK({ - artists: { - index: [ - { - name: "#", - }, - { - name: "A", - }, - { - name: "B", - }, - ], - }, - }) - ) - ) - ); - }); - - it("should return empty", async () => { - const artists = await login({ username, password }) - .then((it) => it.artists({ _index: 0, _count: 100 })); - - expect(artists).toEqual({ - results: [], - total: 0, - }); - }); - }); - - describe("when there no indexes and no artists", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - subsonicOK({ - artists: {}, - }) - ) - ) - ); - }); - - it("should return empty", async () => { - const artists = await login({ username, password }) - .then((it) => it.artists({ _index: 0, _count: 100 })); - - expect(artists).toEqual({ - results: [], - total: 0, - }); - }); - }); - - describe("when there is one index and one artist", () => { - const artist1 = anArtist({albums:[anAlbum(), anAlbum(), anAlbum(), anAlbum()]}); - - const asArtistsJson = subsonicOK({ - artists: { - index: [ - { - name: "#", - artist: [ - { - id: artist1.id, - name: artist1.name, - albumCount: artist1.albums.length, - }, - ], - }, - ], - }, - }); - - describe("when it all fits on one page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson))); - }); - - it("should return the single artist", async () => { - const artists = await login({ username, password }) - .then((it) => it.artists({ _index: 0, _count: 100 })); - - const expectedResults = [{ - id: artist1.id, - image: artist1.image, - name: artist1.name, - }]; - - expect(artists).toEqual({ - results: expectedResults, - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - }); - - describe("when there are artists", () => { - const artist1 = anArtist({ name: "A Artist", albums:[anAlbum()] }); - const artist2 = anArtist({ name: "B Artist" }); - const artist3 = anArtist({ name: "C Artist" }); - const artist4 = anArtist({ name: "D Artist" }); - const artists = [artist1, artist2, artist3, artist4]; - - describe("when no paging is in effect", () => { - 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, - }) - ); - - 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, - })); - - expect(artists).toEqual({ results: expectedResults, total: 4 }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - }); - }); - - 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..0fbb936 --- /dev/null +++ b/tests/subsonic/generic.test.ts @@ -0,0 +1,4713 @@ +import { pipe } from "fp-ts/lib/function"; +import { option as O } from "fp-ts"; +import { v4 as uuid } from "uuid"; + +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 { 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 { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; +import { b64Encode } from "../../src/b64"; +import { http2From } 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 }, + 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 mockAxios = axios as unknown as jest.Mock; + const mockRandomstring = 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 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( + streamClientApplication, + // todo: all this stuff doesnt need to be defaulted in here. + http2From(mockAxios).with({ + baseURL, + params: authParams, + headers + }) + ); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + + randomstring.generate = mockRandomstring; + axios.post = mockPOST; + + mockRandomstring.mockReturnValue(salt); + }); + + + 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(() => { + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson([]))) + ); + }); + + it("should return empty array", async () => { + const result = await generic.genres(); + + expect(result).toEqual([]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getGenres`, + params: authParamsPlusJson, + headers, + }); + }); + }); + + describe("when there is only 1 that has an albumCount > 0", () => { + const genres = [ + { name: "genre1", albumCount: 1 }, + { name: "genreWithNoAlbums", albumCount: 0 }, + ]; + + beforeEach(() => { + mockAxios.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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getGenres`, + params: 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(() => { + mockAxios.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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getGenres`, + params: 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(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { + ...authParamsPlusJson, + id: artist.id, + }, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { + ...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(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { + ...authParamsPlusJson, + id: artist.id, + }, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { + ...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(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { + ...authParamsPlusJson, + id: artist.id, + }, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { + ...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(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { + ...authParamsPlusJson, + id: artist.id, + }, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { + ...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(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { + ...authParamsPlusJson, + id: artist.id, + }, + headers, + }); + + 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({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { + ...authParamsPlusJson, + id: artist.id, + }, + headers, + }); + + 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({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { + ...authParamsPlusJson, + id: artist.id, + }, + headers, + }); + + 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") }); + + const album2: Album = anAlbum({ genre: asGenre("Flop") }); + + const artist: Artist = anArtist({ + albums: [album1, album2], + similarArtists: [], + }); + + beforeEach(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { + ...authParamsPlusJson, + id: artist.id, + }, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { + ...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(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { + ...authParamsPlusJson, + id: artist.id, + }, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }, + headers, + }); + }); + }); + + describe("and has no albums", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + beforeEach(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtist`, + params: { + ...authParamsPlusJson, + id: artist.id, + }, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtistInfo2`, + params: { + ...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(() => { + 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(() => { + 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 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(() => { + 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, + }, + ]; + + expect(artists).toEqual({ + results: expectedResults, + total: 1, + }); + + 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 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(() => { + 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, + image: it.image, + name: it.name, + sortName: it.name, + }) + ); + + expect(artists).toEqual({ + results: expectedResults, + total: 4, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + }); + }); + + describe("when paging specified", () => { + beforeEach(() => { + 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(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 = { + // 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(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...authParamsPlusJson, + type: "byGenre", + genre: "Pop", + size: 500, + offset: 0, + }, + headers, + }); + }); + }); + + describe("by newest", () => { + beforeEach(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...authParamsPlusJson, + type: "newest", + size: 500, + offset: 0, + }, + headers, + }); + }); + }); + + describe("by recently played", () => { + beforeEach(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...authParamsPlusJson, + type: "recent", + size: 500, + offset: 0, + }, + headers, + }); + }); + }); + + describe("by frequently played", () => { + beforeEach(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...authParamsPlusJson, + type: "frequent", + size: 500, + offset: 0, + }, + headers, + }); + }); + }); + + describe("by starred", () => { + beforeEach(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...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(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...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(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...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 () => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...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 () => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...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(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }, + headers, + } + ); + }); + }); + + describe("when the query is for the first page", () => { + beforeEach(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }, + headers, + } + ); + }); + }); + + describe("when the query is for the last page only", () => { + beforeEach(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...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(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }, + headers, + } + ); + }); + }); + + describe("when the query is for the first page", () => { + beforeEach(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }, + headers, + } + ); + }); + }); + + describe("when the query is for the last page only", () => { + beforeEach(() => { + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getArtists`, + params: authParamsPlusJson, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbumList2`, + params: { + ...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(() => { + mockAxios.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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbum`, + params: { + ...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(() => { + mockAxios.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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbum`, + params: { + ...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(() => { + mockAxios.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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbum`, + params: { + ...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(() => { + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); + }); + + it("should empty array", async () => { + const result = await generic.tracks(album.id); + + expect(result).toEqual([]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: '/rest/getAlbum', + params: { + ...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, + }, + }); + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getSong`, + params: { + ...authParamsPlusJson, + id: track.id, + }, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbum`, + params: { + ...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, + }, + }); + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getSong`, + params: { + ...authParamsPlusJson, + id: track.id, + }, + headers, + }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getAlbum`, + params: { + ...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, + }; + + mockAxios.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, + }; + + mockAxios.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, + }; + + mockAxios.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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/stream`, + params: { + ...authParams, + id: trackId, + }, + headers: { + "User-Agent": "bonob", + }, + responseType: "stream", + }); + }); + }); + + // 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"; + + const streamResponse = { + status: 400, + headers: { + "content-type": "text/html", + "content-length": "33", + }, + }; + + mockAxios.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"; + + mockAxios.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, + }; + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: "get", + baseURL, + url: `/rest/stream`, + params: { + ...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"), + }; + + mockAxios.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(mockAxios).toHaveBeenCalledWith({ + method: "get", + baseURL, + url: `/rest/stream`, + params: { + ...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"), + }; + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/stream`, + params: { + ...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}`, + }; + + mockAxios.mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); + + const result = await generic.coverArt(coverArtURN); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(mockAxios).toHaveBeenCalledWith( + streamRequest( + streamRequest({ + url: `/rest/getCoverArt`, + params: { + id: coverArtId, + }, + }) + ) + ); + }); + }); + + 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; + + mockAxios.mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); + + const result = await generic.coverArt(coverArtURN, size); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(mockAxios).toHaveBeenLastCalledWith( + streamRequest({ + url: `/rest/getCoverArt`, + params: { + id: coverArtId, + size, + }, + }) + ); + }); + }); + + describe("when an unexpected error occurs", () => { + it("should return undefined", async () => { + const size = 1879; + + mockAxios.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()}`, + }; + + 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"), + }; + + mockAxios.mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); + + const result = await generic.coverArt(covertArtURN); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(mockAxios).toHaveBeenCalledWith( + streamRequest({ + url: `/rest/getCoverArt`, + params: { + id: coverArtId, + }, + }) + ); + }); + + describe("and an error occurs fetching the uri", () => { + it("should return undefined", async () => { + const coverArtId = uuid(); + const covertArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; + + mockAxios.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"), + }; + + mockAxios.mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); + + const result = await generic.coverArt(covertArtURN, size); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(mockAxios).toHaveBeenCalledWith( + streamRequest({ + url: `/rest/getCoverArt`, + params: { + id: coverArtId, + size, + }, + }) + ); + }); + + describe("and an error occurs fetching the uri", () => { + it("should return undefined", async () => { + const coverArtId = uuid(); + const covertArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; + + mockAxios.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 }, + }); + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/star`, + params: { + ...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 }, + }); + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/unstar`, + params: { + ...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 }, + }); + + mockAxios + .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(mockAxios).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 }, + }); + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/setRating`, + params: { + ...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 }, + }); + + mockAxios + .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(mockAxios).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 }, + }); + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/unstar`, + params: { + ...authParamsPlusJson, + id: trackId, + }, + headers, + }); + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/setRating`, + params: { + ...authParamsPlusJson, + id: trackId, + rating: 5, + }, + headers, + }); + }); + }); + + describe("invalid star values", () => { + describe("stars of -1", () => { + it("should return false", async () => { + const result = await rate(trackId, { love: true, stars: -1 }); + expect(result).toEqual(false); + }); + }); + + describe("stars of 6", () => { + it("should return false", async () => { + const result = await rate(trackId, { love: true, stars: -1 }); + expect(result).toEqual(false); + }); + }); + }); + + describe("when fails", () => { + it("should return false", async () => { + mockAxios + .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(); + + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.scrobble(id); + + expect(result).toEqual(true); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/scrobble`, + params: { + ...authParamsPlusJson, + id, + submission: true, + }, + headers, + }); + }); + }); + + describe("when fails", () => { + it("should return false", async () => { + const id = uuid(); + + mockAxios.mockImplementationOnce(() => + Promise.resolve({ + status: 500, + data: {}, + }) + ); + + const result = await generic.scrobble(id); + + expect(result).toEqual(false); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/scrobble`, + params: { + ...authParamsPlusJson, + id, + submission: true, + }, + headers, + }); + }); + }); + }); + + describe("nowPlaying", () => { + describe("when succeeds", () => { + it("should return true", async () => { + const id = uuid(); + + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.nowPlaying(id); + + expect(result).toEqual(true); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/scrobble`, + params: { + ...authParamsPlusJson, + id, + submission: false, + }, + headers, + }); + }); + }); + + describe("when fails", () => { + it("should return false", async () => { + const id = uuid(); + + mockAxios.mockImplementationOnce(() => + Promise.resolve({ + status: 500, + data: {}, + }) + ); + + const result = await generic.nowPlaying(id); + + expect(result).toEqual(false); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/scrobble`, + params: { + ...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" }); + + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ artists: [artist1] }))) + ); + + const result = await generic.searchArtists("foo"); + + expect(result).toEqual([artistToArtistSummary(artist1)]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { + ...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" }); + + mockAxios.mockImplementationOnce(() => + Promise.resolve( + ok(getSearchResult3Json({ artists: [artist1, artist2] })) + ) + ); + + const result = await generic.searchArtists("foo"); + + expect(result).toEqual([ + artistToArtistSummary(artist1), + artistToArtistSummary(artist2), + ]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { + ...authParamsPlusJson, + artistCount: 20, + albumCount: 0, + songCount: 0, + query: "foo", + }, + headers, + }); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) + ); + + const result = await generic.searchArtists("foo"); + + expect(result).toEqual([]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { + ...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] }); + + mockAxios.mockImplementationOnce(() => + Promise.resolve( + ok(getSearchResult3Json({ albums: [{ artist, album }] })) + ) + ); + + const result = await generic.searchAlbums("foo"); + + expect(result).toEqual([albumToAlbumSummary(album)]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { + ...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] }); + + mockAxios.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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { + ...authParamsPlusJson, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "moo", + }, + headers, + }); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ albums: [] }))) + ); + + const result = await generic.searchAlbums("foo"); + + expect(result).toEqual([]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { + ...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, + }); + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { + ...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, + }); + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method:'get', + baseURL, + url: `/rest/search3`, + params: { + ...authParamsPlusJson, + artistCount: 0, + albumCount: 0, + songCount: 20, + query: "moo", + }, + headers, + }); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ tracks: [] }))) + ); + + const result = await generic.searchTracks("foo"); + + expect(result).toEqual([]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/search3`, + params: { + ...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(); + + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson([playlist]))) + ); + + const result = await generic.playlists(); + + expect(result).toEqual([playlist]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getPlaylists`, + params: 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]; + + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson(playlists))) + ); + + const result = await generic.playlists(); + + expect(result).toEqual(playlists); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getPlaylists`, + params: authParamsPlusJson, + headers, + }); + }); + }); + + describe("when there are no playlists", () => { + it("should return []", async () => { + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson([]))) + ); + + const result = await generic.playlists(); + + expect(result).toEqual([]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getPlaylists`, + params: authParamsPlusJson, + headers, + }); + }); + }); + }); + + describe("getting a single playlist", () => { + describe("when there is no playlist with the id", () => { + it("should raise error", async () => { + const id = "id404"; + + mockAxios.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), + }); + + mockAxios.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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getPlaylist`, + params: { + ...authParamsPlusJson, + id, + }, + headers, + }); + }); + }); + + describe("and it has no tracks", () => { + it("should return the playlist with empty entries", async () => { + const playlist = aPlaylist({ + entries: [], + }); + + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListJson(playlist))) + ); + + const result = await generic.playlist(playlist.id); + + expect(result).toEqual(playlist); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getPlaylist`, + params: { + ...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(); + + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(createPlayListJson({ id, name }))) + ); + + const result = await generic.createPlaylist(name); + + expect(result).toEqual({ id, name }); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/createPlaylist`, + params: { + ...authParamsPlusJson, + f: "json", + name, + }, + headers, + }); + }); + }); + + describe("deleting a playlist", () => { + it("should delete the playlist by id", async () => { + const id = "id-to-delete"; + + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.deletePlaylist(id); + + expect(result).toEqual(true); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/deletePlaylist`, + params: { + ...authParamsPlusJson, + id, + }, + headers, + }); + }); + }); + + describe("editing playlists", () => { + describe("adding a track to a playlist", () => { + it("should add it", async () => { + const playlistId = uuid(); + const trackId = uuid(); + + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.addToPlaylist(playlistId, trackId); + + expect(result).toEqual(true); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/updatePlaylist`, + params: { + ...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]; + + mockAxios.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.removeFromPlaylist(playlistId, indicies); + + expect(result).toEqual(true); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/updatePlaylist`, + params: { + ...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, + }); + + mockAxios + .mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([track1]))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) + ); + + const result = await generic.similarSongs(id); + + expect(result).toEqual([track1]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getSimilarSongs2`, + params: { + ...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, + }); + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getSimilarSongs2`, + params: { + ...authParams, + f: "json", + id, + count: 50, + }, + headers, + }); + }); + }); + + describe("when there are no similar songs", () => { + it("should return []", async () => { + const id = "idWithNoTracks"; + + mockAxios.mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([]))) + ); + + const result = await generic.similarSongs(id); + + expect(result).toEqual([]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getSimilarSongs2`, + params: { + ...authParams, + f: "json", + id, + count: 50, + }, + headers, + }); + }); + }); + + describe("when the id doesnt exist", () => { + it("should fail", async () => { + const id = "idThatHasAnError"; + + mockAxios.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, + }); + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getTopSongs`, + params: { + ...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, + }); + + mockAxios + .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(mockAxios).toHaveBeenCalledWith({ + method: 'get', + baseURL, + url: `/rest/getTopSongs`, + params: { + ...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], + }); + + mockAxios + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getTopSongsJson([]))) + ); + + const result = await generic.topSongs(artistId); + + expect(result).toEqual([]); + + expect(mockAxios).toHaveBeenCalledWith({ + method: 'get' , + baseURL, + url: `/rest/getTopSongs`, + params: { + ...authParams, + f: "json", + artist: artistName, + count: 50, + }, + headers, + } + ); + }); + }); + }); +}); 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 }) + }); + }); + }); +}); + diff --git a/tests/utils.test.ts b/tests/utils.test.ts index ce0d5f3..3d1517a 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,4 +1,50 @@ -import { takeWithRepeats } from "../src/utils"; +import { asURLSearchParams, mask, 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", () => { @@ -29,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); + } + ); +});