From 50cb5b2550c7b2a12f50e795a3ea4013295e4dae Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 7 Jan 2022 13:26:19 +1100 Subject: [PATCH] ref --- src/subsonic.ts | 786 +----- src/subsonic/generic.ts | 728 ++++++ tests/builders.ts | 2 +- tests/subsonic.test.ts | 4463 +------------------------------- tests/subsonic/generic.test.ts | 4390 +++++++++++++++++++++++++++++++ 5 files changed, 5163 insertions(+), 5206 deletions(-) create mode 100644 src/subsonic/generic.ts create mode 100644 tests/subsonic/generic.test.ts diff --git a/src/subsonic.ts b/src/subsonic.ts index c255e7b..1ea4765 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -1,36 +1,25 @@ -import { option as O, taskEither as TE } from "fp-ts"; -import * as A from "fp-ts/Array"; -import { ordString } from "fp-ts/lib/Ord"; +import { taskEither as TE } from "fp-ts"; import { pipe } from "fp-ts/lib/function"; import { Md5 } from "ts-md5/dist/md5"; import { Credentials, MusicService, - Album, Result, - slice2, - AlbumQuery, ArtistQuery, MusicLibrary, - AlbumSummary, - Genre, Track, - Rating, - AlbumQueryType, - Artist, AuthFailure, Sortable, + ArtistSummary, } from "./music_service"; import _ from "underscore"; import axios, { AxiosRequestConfig } from "axios"; import randomstring from "randomstring"; import { b64Encode, b64Decode } from "./b64"; -import logger from "./logger"; -import { assertSystem, BUrn } from "./burn"; -import { artist } from "./smapi"; import { axiosImageFetcher, ImageFetcher } from "./images"; import { asURLSearchParams } from "./utils"; +import { artistImageURN, SubsonicGenericMusicLibrary } from "./subsonic/generic"; export const t = (password: string, s: string) => @@ -44,154 +33,26 @@ export const t_and_s = (password: string) => { }; }; +// todo: this is an ND thing export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png"; -export const isValidImage = (url: string | undefined) => - url != undefined && !url.endsWith(DODGY_IMAGE_NAME); + type SubsonicEnvelope = { "subsonic-response": SubsonicResponse; }; -type SubsonicResponse = { +export type SubsonicResponse = { status: string; }; -type album = { - id: string; - name: string; - artist: string | undefined; - artistId: string | undefined; - coverArt: string | undefined; - genre: string | undefined; - year: string | undefined; -}; - -type artist = { - id: string; - name: string; - albumCount: number; - artistImageUrl: string | undefined; -}; - -type GetArtistsResponse = SubsonicResponse & { - artists: { - index: { - artist: artist[]; - name: string; - }[]; - }; -}; - -type GetAlbumListResponse = SubsonicResponse & { - albumList2: { - album: album[]; - }; -}; - -type genre = { - songCount: number; - albumCount: number; - value: string; -}; - -type GetGenresResponse = SubsonicResponse & { - genres: { - genre: genre[]; - }; -}; - -type SubsonicError = SubsonicResponse & { +export type SubsonicError = SubsonicResponse & { error: { code: string; message: string; }; }; -export type images = { - smallImageUrl: string | undefined; - mediumImageUrl: string | undefined; - largeImageUrl: string | undefined; -}; - -type artistInfo = images & { - biography: string | undefined; - musicBrainzId: string | undefined; - lastFmUrl: string | undefined; - similarArtist: artist[]; -}; - -type ArtistSummary = IdName & { - image: BUrn | undefined; -}; - -type GetArtistInfoResponse = SubsonicResponse & { - artistInfo2: artistInfo; -}; - -type GetArtistResponse = SubsonicResponse & { - artist: artist & { - album: album[]; - }; -}; - -export type song = { - id: string; - parent: string | undefined; - title: string; - album: string | undefined; - albumId: string | undefined; - artist: string | undefined; - artistId: string | undefined; - track: number | undefined; - year: string | undefined; - genre: string | undefined; - coverArt: string | undefined; - created: string | undefined; - duration: number | undefined; - bitRate: number | undefined; - suffix: string | undefined; - contentType: string | undefined; - type: string | undefined; - userRating: number | undefined; - starred: string | undefined; -}; - -type GetAlbumResponse = { - album: album & { - song: song[]; - }; -}; - -type playlist = { - id: string; - name: string; -}; - -type GetPlaylistResponse = { - playlist: { - id: string; - name: string; - entry: song[]; - }; -}; - -type GetPlaylistsResponse = { - playlists: { playlist: playlist[] }; -}; - -type GetSimilarSongsResponse = { - similarSongs2: { song: song[] }; -}; - -type GetTopSongsResponse = { - topSongs: { song: song[] }; -}; - -type GetSongResponse = { - song: song; -}; - export type PingResponse = { status: string; version: string; @@ -199,14 +60,6 @@ export type PingResponse = { serverVersion: string; }; -type Search3Response = SubsonicResponse & { - searchResult3: { - artist: artist[]; - album: album[]; - song: song[]; - }; -}; - export function isError( subsonicResponse: SubsonicResponse ): subsonicResponse is SubsonicError { @@ -220,18 +73,38 @@ export type NDArtist = { largeImageUrl: string | undefined; }; -type IdName = { - id: string; - name: string; + + +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; }; -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 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 const artistSummaryFromNDArtist = ( artist: NDArtist @@ -245,591 +118,6 @@ export const artistSummaryFromNDArtist = ( }), }); -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"; -} - -const AlbumQueryTypeToSubsonicType: Record = { - alphabeticalByArtist: "alphabeticalByArtist", - alphabeticalByName: "alphabeticalByName", - byGenre: "byGenre", - random: "random", - recentlyPlayed: "recent", - mostPlayed: "frequent", - recentlyAdded: "newest", - favourited: "starred", - starred: "highest", -}; - -const artistIsInLibrary = (artistId: string | undefined) => - artistId != undefined && artistId != "-1"; - -export type SubsonicCredentials = Credentials & { - type: string; - 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 SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { - subsonic: Subsonic; - credentials: SubsonicCredentials; - - constructor(subsonic: Subsonic, credentials: SubsonicCredentials) { - this.subsonic = subsonic; - this.credentials = credentials; - } - - flavour = () => "subsonic"; - - bearerToken = (_: Credentials) => TE.right(undefined); - - artists = (q: ArtistQuery): Promise> => - this.getArtists() - .then(slice2(q)) - .then(([page, total]) => ({ - total, - results: page.map((it) => ({ - id: it.id, - name: it.name, - sortName: it.name, - image: it.image, - })), - })); - - artist = async (id: string): Promise => - this.getArtistWithInfo(id); - - albums = async (q: AlbumQuery): Promise> => - this.getAlbumList2(q); - - album = (id: string): Promise => this.getAlbum(id); - - genres = () => - this.subsonic - .getJSON(this.credentials, "/rest/getGenres") - .then((it) => - pipe( - it.genres.genre || [], - A.filter((it) => it.albumCount > 0), - A.map((it) => it.value), - A.sort(ordString), - A.map((it) => ({ id: b64Encode(it), name: it })) - ) - ); - - tracks = (albumId: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getAlbum", { - id: albumId, - }) - .then((it) => it.album) - .then((album) => - (album.song || []).map((song) => asTrack(asAlbum(album), song)) - ); - - track = (trackId: string) => this.getTrack(trackId); - - rate = (trackId: string, rating: Rating) => - Promise.resolve(true) - .then(() => { - if (rating.stars >= 0 && rating.stars <= 5) { - return this.getTrack(trackId); - } else { - throw `Invalid rating.stars value of ${rating.stars}`; - } - }) - .then((track) => { - const thingsToUpdate = []; - if (track.rating.love != rating.love) { - thingsToUpdate.push( - this.subsonic.getJSON( - this.credentials, - `/rest/${rating.love ? "star" : "unstar"}`, - { - id: trackId, - } - ) - ); - } - if (track.rating.stars != rating.stars) { - thingsToUpdate.push( - this.subsonic.getJSON(this.credentials, `/rest/setRating`, { - id: trackId, - rating: rating.stars, - }) - ); - } - return Promise.all(thingsToUpdate); - }) - .then(() => true) - .catch(() => false); - - stream = async ({ - trackId, - range, - }: { - trackId: string; - range: string | undefined; - }) => - this.getTrack(trackId).then((track) => - this.subsonic - .get( - this.credentials, - `/rest/stream`, - { - id: trackId, - c: this.subsonic.streamClientApplication(track), - }, - { - headers: pipe( - range, - O.fromNullable, - O.map((range) => ({ - "User-Agent": USER_AGENT, - Range: range, - })), - O.getOrElse(() => ({ - "User-Agent": USER_AGENT, - })) - ), - responseType: "stream", - } - ) - .then((res) => ({ - status: res.status, - headers: { - "content-type": res.headers["content-type"], - "content-length": res.headers["content-length"], - "content-range": res.headers["content-range"], - "accept-ranges": res.headers["accept-ranges"], - }, - stream: res.data, - })) - ); - - coverArt = async (coverArtURN: BUrn, size?: number) => - Promise.resolve(coverArtURN) - .then((it) => assertSystem(it, "subsonic")) - .then((it) => it.resource.split(":")[1]!) - .then((it) => this.getCoverArt(this.credentials, it, size)) - .then((res) => ({ - contentType: res.headers["content-type"], - data: Buffer.from(res.data, "binary"), - })) - .catch((e) => { - logger.error(`Failed getting coverArt for urn:'${coverArtURN}': ${e}`); - return undefined; - }); - - scrobble = async (id: string) => - this.subsonic - .getJSON(this.credentials, `/rest/scrobble`, { - id, - submission: true, - }) - .then((_) => true) - .catch(() => false); - - nowPlaying = async (id: string) => - this.subsonic - .getJSON(this.credentials, `/rest/scrobble`, { - id, - submission: false, - }) - .then((_) => true) - .catch(() => false); - - searchArtists = async (query: string) => - this.search3({ query, artistCount: 20 }).then( - ({ artists }) => - artists.map((artist) => ({ - id: artist.id, - name: artist.name, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: artist.artistImageUrl, - }), - })) - ); - - searchAlbums = async (query: string) => - this.search3({ query, albumCount: 20 }).then( - ({ albums }) => this.toAlbumSummary(albums) - ); - - searchTracks = async (query: string) => - this.search3({ query, songCount: 20 }).then(({ songs }) => - Promise.all(songs.map((it) => this.getTrack(it.id))) - ); - - playlists = async () => - this.subsonic - .getJSON(this.credentials, "/rest/getPlaylists") - .then((it) => it.playlists.playlist || []) - .then((playlists) => - playlists.map((it) => ({ id: it.id, name: it.name })) - ); - - playlist = async (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getPlaylist", { - id, - }) - .then((it) => it.playlist) - .then((playlist) => { - let trackNumber = 1; - return { - id: playlist.id, - name: playlist.name, - entries: (playlist.entry || []).map((entry) => ({ - ...asTrack( - { - id: entry.albumId!, - name: entry.album!, - year: entry.year, - genre: maybeAsGenre(entry.genre), - artistName: entry.artist, - artistId: entry.artistId, - coverArt: coverArtURN(entry.coverArt), - }, - entry - ), - number: trackNumber++, - })), - }; - }); - - createPlaylist = async (name: string) => - this.subsonic - .getJSON(this.credentials, "/rest/createPlaylist", { - name, - }) - .then((it) => it.playlist) - .then((it) => ({ id: it.id, name: it.name })); - - deletePlaylist = async (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/deletePlaylist", { - id, - }) - .then((_) => true); - - addToPlaylist = async (playlistId: string, trackId: string) => - this.subsonic - .getJSON(this.credentials, "/rest/updatePlaylist", { - playlistId, - songIdToAdd: trackId, - }) - .then((_) => true); - - removeFromPlaylist = async (playlistId: string, indicies: number[]) => - this.subsonic - .getJSON(this.credentials, "/rest/updatePlaylist", { - playlistId, - songIndexToRemove: indicies, - }) - .then((_) => true); - - similarSongs = async (id: string) => - this.subsonic - .getJSON( - this.credentials, - "/rest/getSimilarSongs2", - { id, count: 50 } - ) - .then((it) => it.similarSongs2.song || []) - .then((songs) => - Promise.all( - songs.map((song) => - this.getAlbum(song.albumId!).then((album) => - asTrack(album, song) - ) - ) - ) - ); - - topSongs = async (artistId: string) => - this.getArtist(artistId).then(({ name }) => - this.subsonic - .getJSON(this.credentials, "/rest/getTopSongs", { - artist: name, - count: 50, - }) - .then((it) => it.topSongs.song || []) - .then((songs) => - Promise.all( - songs.map((song) => - this.getAlbum(song.albumId!).then((album) => - asTrack(album, song) - ) - ) - ) - ) - ); - - private getArtists = (): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> => - this.subsonic - .getJSON(this.credentials, "/rest/getArtists") - .then((it) => (it.artists.index || []).flatMap((it) => it.artist || [])) - .then((artists) => - artists.map((artist) => ({ - id: `${artist.id}`, - name: artist.name, - albumCount: artist.albumCount, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: artist.artistImageUrl, - }), - })) - ); - - private getArtistInfo = ( - id: string - ): Promise<{ - similarArtist: (ArtistSummary & { inLibrary: boolean })[]; - images: { - s: string | undefined; - m: string | undefined; - l: string | undefined; - }; - }> => - this.subsonic - .getJSON(this.credentials, "/rest/getArtistInfo2", { - id, - count: 50, - includeNotPresent: true, - }) - .then((it) => it.artistInfo2) - .then((it) => ({ - images: { - s: it.smallImageUrl, - m: it.mediumImageUrl, - l: it.largeImageUrl, - }, - similarArtist: (it.similarArtist || []).map((artist) => ({ - id: `${artist.id}`, - name: artist.name, - inLibrary: artistIsInLibrary(artist.id), - image: artistImageURN({ - artistId: artist.id, - artistImageURL: artist.artistImageUrl, - }), - })), - })); - - private getAlbum = (id: string): Promise => - this.subsonic - .getJSON(this.credentials, "/rest/getAlbum", { id }) - .then((it) => it.album) - .then((album) => ({ - id: album.id, - name: album.name, - year: album.year, - genre: maybeAsGenre(album.genre), - artistId: album.artistId, - artistName: album.artist, - coverArt: coverArtURN(album.coverArt), - })); - - private getArtist = ( - id: string - ): Promise< - IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] } - > => - this.subsonic - .getJSON(this.credentials, "/rest/getArtist", { - id, - }) - .then((it) => it.artist) - .then((it) => ({ - id: it.id, - name: it.name, - artistImageUrl: it.artistImageUrl, - albums: this.toAlbumSummary(it.album || []), - })); - - private getArtistWithInfo = (id: string) => - Promise.all([ - this.getArtist(id), - this.getArtistInfo(id), - ]).then(([artist, artistInfo]) => ({ - id: artist.id, - name: artist.name, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: [ - artist.artistImageUrl, - artistInfo.images.l, - artistInfo.images.m, - artistInfo.images.s, - ].find(isValidImage), - }), - albums: artist.albums, - similarArtists: artistInfo.similarArtist, - })); - - private getCoverArt = (credentials: Credentials, id: string, size?: number) => - this.subsonic.get( - credentials, - "/rest/getCoverArt", - size ? { id, size } : { id }, - { - headers: { "User-Agent": "bonob" }, - responseType: "arraybuffer", - } - ); - - private getTrack = (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getSong", { - id, - }) - .then((it) => it.song) - .then((song) => - this.getAlbum(song.albumId!).then((album) => - asTrack(album, song) - ) - ); - - private toAlbumSummary = (albumList: album[]): AlbumSummary[] => - albumList.map((album) => ({ - id: album.id, - name: album.name, - year: album.year, - genre: maybeAsGenre(album.genre), - artistId: album.artistId, - artistName: album.artist, - coverArt: coverArtURN(album.coverArt), - })); - - private search3 = (q: any) => - this.subsonic - .getJSON(this.credentials, "/rest/search3", { - artistCount: 0, - albumCount: 0, - songCount: 0, - ...q, - }) - .then((it) => ({ - artists: it.searchResult3.artist || [], - albums: it.searchResult3.album || [], - songs: it.searchResult3.song || [], - })); - - private getAlbumList2 = (q: AlbumQuery) => - Promise.all([ - this.getArtists().then((it) => - _.inject(it, (total, artist) => total + artist.albumCount, 0) - ), - this.subsonic - .getJSON(this.credentials, "/rest/getAlbumList2", { - type: AlbumQueryTypeToSubsonicType[q.type], - ...(q.genre ? { genre: b64Decode(q.genre) } : {}), - size: 500, - offset: q._index, - }) - .then((response) => response.albumList2.album || []) - .then(this.toAlbumSummary), - ]).then(([total, albums]) => ({ - results: albums.slice(0, q._count), - total: albums.length == 500 ? total : (q._index || 0) + albums.length, - })); -} - export class Subsonic implements MusicService { url: string; streamClientApplication: StreamClientApplication; diff --git a/src/subsonic/generic.ts b/src/subsonic/generic.ts new file mode 100644 index 0000000..8ba8e18 --- /dev/null +++ b/src/subsonic/generic.ts @@ -0,0 +1,728 @@ +import { option as O, taskEither as TE } from "fp-ts"; +import * as A from "fp-ts/Array"; +import { pipe } from "fp-ts/lib/function"; +import { ordString } from "fp-ts/lib/Ord"; +import { inject } from 'underscore'; + +import logger from "../logger"; +import { b64Decode, b64Encode } from "../b64"; +import { assertSystem, BUrn } from "../burn"; + +import { Album, AlbumQuery, AlbumQueryType, AlbumSummary, Artist, ArtistQuery, Credentials, Genre, Rating, Result, slice2, Sortable, Track } from "../music_service"; +import Subsonic, { DODGY_IMAGE_NAME, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, USER_AGENT } from "../subsonic"; + + +type album = { + id: string; + name: string; + artist: string | undefined; + artistId: string | undefined; + coverArt: string | undefined; + genre: string | undefined; + year: string | undefined; +}; + +type artist = { + id: string; + name: string; + albumCount: number; + artistImageUrl: string | undefined; +}; + +type GetArtistsResponse = SubsonicResponse & { + artists: { + index: { + artist: artist[]; + name: string; + }[]; + }; +}; + +type GetAlbumListResponse = SubsonicResponse & { + albumList2: { + album: album[]; + }; +}; + +type genre = { + songCount: number; + albumCount: number; + value: string; +}; + +type GetGenresResponse = SubsonicResponse & { + genres: { + genre: genre[]; + }; +}; + + +type GetArtistInfoResponse = SubsonicResponse & { + artistInfo2: artistInfo; +}; + +type GetArtistResponse = SubsonicResponse & { + artist: artist & { + album: album[]; + }; +}; + + +export type images = { + smallImageUrl: string | undefined; + mediumImageUrl: string | undefined; + largeImageUrl: string | undefined; +}; + +type artistInfo = images & { + biography: string | undefined; + musicBrainzId: string | undefined; + lastFmUrl: string | undefined; + similarArtist: artist[]; +}; + +type IdName = { + id: string; + name: string; +}; + +type ArtistSummary = IdName & { + image: BUrn | undefined; +}; + +export type song = { + id: string; + parent: string | undefined; + title: string; + album: string | undefined; + albumId: string | undefined; + artist: string | undefined; + artistId: string | undefined; + track: number | undefined; + year: string | undefined; + genre: string | undefined; + coverArt: string | undefined; + created: string | undefined; + duration: number | undefined; + bitRate: number | undefined; + suffix: string | undefined; + contentType: string | undefined; + type: string | undefined; + userRating: number | undefined; + starred: string | undefined; +}; + +type GetAlbumResponse = { + album: album & { + song: song[]; + }; +}; + +type playlist = { + id: string; + name: string; +}; + +type GetPlaylistResponse = { + playlist: { + id: string; + name: string; + entry: song[]; + }; +}; + +type GetPlaylistsResponse = { + playlists: { playlist: playlist[] }; +}; + +type GetSimilarSongsResponse = { + similarSongs2: { song: song[] }; +}; + +type GetTopSongsResponse = { + topSongs: { song: song[] }; +}; + +type GetSongResponse = { + song: song; +}; + + +type Search3Response = SubsonicResponse & { + searchResult3: { + artist: artist[]; + album: album[]; + song: song[]; + }; +}; + +const AlbumQueryTypeToSubsonicType: Record = { + alphabeticalByArtist: "alphabeticalByArtist", + alphabeticalByName: "alphabeticalByName", + byGenre: "byGenre", + random: "random", + recentlyPlayed: "recent", + mostPlayed: "frequent", + recentlyAdded: "newest", + favourited: "starred", + starred: "highest", +}; + + +export const isValidImage = (url: string | undefined) => + url != undefined && !url.endsWith(DODGY_IMAGE_NAME); + +const artistIsInLibrary = (artistId: string | undefined) => + artistId != undefined && artistId != "-1"; + + +const coverArtURN = (coverArt: string | undefined): BUrn | undefined => + pipe( + coverArt, + O.fromNullable, + O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })), + O.getOrElseW(() => undefined) + ); + + + // todo: is this the right place for this?? +export const artistImageURN = ( + spec: Partial<{ + artistId: string | undefined; + artistImageURL: string | undefined; + }> +): BUrn | undefined => { + const deets = { + artistId: undefined, + artistImageURL: undefined, + ...spec, + }; + if (deets.artistImageURL && isValidImage(deets.artistImageURL)) { + return { + system: "external", + resource: deets.artistImageURL, + }; + } else if (artistIsInLibrary(deets.artistId)) { + return { + system: "subsonic", + resource: `art:${deets.artistId!}`, + }; + } else { + return undefined; + } +}; + +export const asTrack = (album: Album, song: song): Track => ({ + id: song.id, + name: song.title, + mimeType: song.contentType!, + duration: song.duration || 0, + number: song.track || 0, + genre: maybeAsGenre(song.genre), + coverArt: coverArtURN(song.coverArt), + album, + artist: { + id: song.artistId, + name: song.artist ? song.artist : "?", + image: song.artistId + ? artistImageURN({ artistId: song.artistId }) + : undefined, + }, + rating: { + love: song.starred != undefined, + stars: + song.userRating && song.userRating <= 5 && song.userRating >= 0 + ? song.userRating + : 0, + }, +}); + +const asAlbum = (album: album): Album => ({ + id: album.id, + name: album.name, + year: album.year, + genre: maybeAsGenre(album.genre), + artistId: album.artistId, + artistName: album.artist, + coverArt: coverArtURN(album.coverArt), +}); + +export const asGenre = (genreName: string) => ({ + id: b64Encode(genreName), + name: genreName, +}); + +const maybeAsGenre = (genreName: string | undefined): Genre | undefined => + pipe( + genreName, + O.fromNullable, + O.map(asGenre), + O.getOrElseW(() => undefined) + ); + + +export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { + subsonic: Subsonic; + credentials: SubsonicCredentials; + + constructor(subsonic: Subsonic, credentials: SubsonicCredentials) { + this.subsonic = subsonic; + this.credentials = credentials; + } + + flavour = () => "subsonic"; + + bearerToken = (_: Credentials) => TE.right(undefined); + + artists = (q: ArtistQuery): Promise> => + this.getArtists() + .then(slice2(q)) + .then(([page, total]) => ({ + total, + results: page.map((it) => ({ + id: it.id, + name: it.name, + sortName: it.name, + image: it.image, + })), + })); + + artist = async (id: string): Promise => + this.getArtistWithInfo(id); + + albums = async (q: AlbumQuery): Promise> => + this.getAlbumList2(q); + + album = (id: string): Promise => this.getAlbum(id); + + genres = () => + this.subsonic + .getJSON(this.credentials, "/rest/getGenres") + .then((it) => + pipe( + it.genres.genre || [], + A.filter((it) => it.albumCount > 0), + A.map((it) => it.value), + A.sort(ordString), + A.map((it) => ({ id: b64Encode(it), name: it })) + ) + ); + + tracks = (albumId: string) => + this.subsonic + .getJSON(this.credentials, "/rest/getAlbum", { + id: albumId, + }) + .then((it) => it.album) + .then((album) => + (album.song || []).map((song) => asTrack(asAlbum(album), song)) + ); + + track = (trackId: string) => this.getTrack(trackId); + + rate = (trackId: string, rating: Rating) => + Promise.resolve(true) + .then(() => { + if (rating.stars >= 0 && rating.stars <= 5) { + return this.getTrack(trackId); + } else { + throw `Invalid rating.stars value of ${rating.stars}`; + } + }) + .then((track) => { + const thingsToUpdate = []; + if (track.rating.love != rating.love) { + thingsToUpdate.push( + this.subsonic.getJSON( + this.credentials, + `/rest/${rating.love ? "star" : "unstar"}`, + { + id: trackId, + } + ) + ); + } + if (track.rating.stars != rating.stars) { + thingsToUpdate.push( + this.subsonic.getJSON(this.credentials, `/rest/setRating`, { + id: trackId, + rating: rating.stars, + }) + ); + } + return Promise.all(thingsToUpdate); + }) + .then(() => true) + .catch(() => false); + + stream = async ({ + trackId, + range, + }: { + trackId: string; + range: string | undefined; + }) => + this.getTrack(trackId).then((track) => + this.subsonic + .get( + this.credentials, + `/rest/stream`, + { + id: trackId, + c: this.subsonic.streamClientApplication(track), + }, + { + headers: pipe( + range, + O.fromNullable, + O.map((range) => ({ + "User-Agent": USER_AGENT, + Range: range, + })), + O.getOrElse(() => ({ + "User-Agent": USER_AGENT, + })) + ), + responseType: "stream", + } + ) + .then((res) => ({ + status: res.status, + headers: { + "content-type": res.headers["content-type"], + "content-length": res.headers["content-length"], + "content-range": res.headers["content-range"], + "accept-ranges": res.headers["accept-ranges"], + }, + stream: res.data, + })) + ); + + coverArt = async (coverArtURN: BUrn, size?: number) => + Promise.resolve(coverArtURN) + .then((it) => assertSystem(it, "subsonic")) + .then((it) => it.resource.split(":")[1]!) + .then((it) => this.getCoverArt(this.credentials, it, size)) + .then((res) => ({ + contentType: res.headers["content-type"], + data: Buffer.from(res.data, "binary"), + })) + .catch((e) => { + logger.error(`Failed getting coverArt for urn:'${coverArtURN}': ${e}`); + return undefined; + }); + + scrobble = async (id: string) => + this.subsonic + .getJSON(this.credentials, `/rest/scrobble`, { + id, + submission: true, + }) + .then((_) => true) + .catch(() => false); + + nowPlaying = async (id: string) => + this.subsonic + .getJSON(this.credentials, `/rest/scrobble`, { + id, + submission: false, + }) + .then((_) => true) + .catch(() => false); + + searchArtists = async (query: string) => + this.search3({ query, artistCount: 20 }).then( + ({ artists }) => + artists.map((artist) => ({ + id: artist.id, + name: artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), + })) + ); + + searchAlbums = async (query: string) => + this.search3({ query, albumCount: 20 }).then( + ({ albums }) => this.toAlbumSummary(albums) + ); + + searchTracks = async (query: string) => + this.search3({ query, songCount: 20 }).then(({ songs }) => + Promise.all(songs.map((it) => this.getTrack(it.id))) + ); + + playlists = async () => + this.subsonic + .getJSON(this.credentials, "/rest/getPlaylists") + .then((it) => it.playlists.playlist || []) + .then((playlists) => + playlists.map((it) => ({ id: it.id, name: it.name })) + ); + + playlist = async (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/getPlaylist", { + id, + }) + .then((it) => it.playlist) + .then((playlist) => { + let trackNumber = 1; + return { + id: playlist.id, + name: playlist.name, + entries: (playlist.entry || []).map((entry) => ({ + ...asTrack( + { + id: entry.albumId!, + name: entry.album!, + year: entry.year, + genre: maybeAsGenre(entry.genre), + artistName: entry.artist, + artistId: entry.artistId, + coverArt: coverArtURN(entry.coverArt), + }, + entry + ), + number: trackNumber++, + })), + }; + }); + + createPlaylist = async (name: string) => + this.subsonic + .getJSON(this.credentials, "/rest/createPlaylist", { + name, + }) + .then((it) => it.playlist) + .then((it) => ({ id: it.id, name: it.name })); + + deletePlaylist = async (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/deletePlaylist", { + id, + }) + .then((_) => true); + + addToPlaylist = async (playlistId: string, trackId: string) => + this.subsonic + .getJSON(this.credentials, "/rest/updatePlaylist", { + playlistId, + songIdToAdd: trackId, + }) + .then((_) => true); + + removeFromPlaylist = async (playlistId: string, indicies: number[]) => + this.subsonic + .getJSON(this.credentials, "/rest/updatePlaylist", { + playlistId, + songIndexToRemove: indicies, + }) + .then((_) => true); + + similarSongs = async (id: string) => + this.subsonic + .getJSON( + this.credentials, + "/rest/getSimilarSongs2", + { id, count: 50 } + ) + .then((it) => it.similarSongs2.song || []) + .then((songs) => + Promise.all( + songs.map((song) => + this.getAlbum(song.albumId!).then((album) => + asTrack(album, song) + ) + ) + ) + ); + + topSongs = async (artistId: string) => + this.getArtist(artistId).then(({ name }) => + this.subsonic + .getJSON(this.credentials, "/rest/getTopSongs", { + artist: name, + count: 50, + }) + .then((it) => it.topSongs.song || []) + .then((songs) => + Promise.all( + songs.map((song) => + this.getAlbum(song.albumId!).then((album) => + asTrack(album, song) + ) + ) + ) + ) + ); + + private getArtists = (): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> => + this.subsonic + .getJSON(this.credentials, "/rest/getArtists") + .then((it) => (it.artists.index || []).flatMap((it) => it.artist || [])) + .then((artists) => + artists.map((artist) => ({ + id: `${artist.id}`, + name: artist.name, + albumCount: artist.albumCount, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), + })) + ); + + private getArtistInfo = ( + id: string + ): Promise<{ + similarArtist: (ArtistSummary & { inLibrary: boolean })[]; + images: { + s: string | undefined; + m: string | undefined; + l: string | undefined; + }; + }> => + this.subsonic + .getJSON(this.credentials, "/rest/getArtistInfo2", { + id, + count: 50, + includeNotPresent: true, + }) + .then((it) => it.artistInfo2) + .then((it) => ({ + images: { + s: it.smallImageUrl, + m: it.mediumImageUrl, + l: it.largeImageUrl, + }, + similarArtist: (it.similarArtist || []).map((artist) => ({ + id: `${artist.id}`, + name: artist.name, + inLibrary: artistIsInLibrary(artist.id), + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), + })), + })); + + private getAlbum = (id: string): Promise => + this.subsonic + .getJSON(this.credentials, "/rest/getAlbum", { id }) + .then((it) => it.album) + .then((album) => ({ + id: album.id, + name: album.name, + year: album.year, + genre: maybeAsGenre(album.genre), + artistId: album.artistId, + artistName: album.artist, + coverArt: coverArtURN(album.coverArt), + })); + + private getArtist = ( + id: string + ): Promise< + IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] } + > => + this.subsonic + .getJSON(this.credentials, "/rest/getArtist", { + id, + }) + .then((it) => it.artist) + .then((it) => ({ + id: it.id, + name: it.name, + artistImageUrl: it.artistImageUrl, + albums: this.toAlbumSummary(it.album || []), + })); + + private getArtistWithInfo = (id: string) => + Promise.all([ + this.getArtist(id), + this.getArtistInfo(id), + ]).then(([artist, artistInfo]) => ({ + id: artist.id, + name: artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: [ + artist.artistImageUrl, + artistInfo.images.l, + artistInfo.images.m, + artistInfo.images.s, + ].find(isValidImage), + }), + albums: artist.albums, + similarArtists: artistInfo.similarArtist, + })); + + private getCoverArt = (credentials: Credentials, id: string, size?: number) => + this.subsonic.get( + credentials, + "/rest/getCoverArt", + size ? { id, size } : { id }, + { + headers: { "User-Agent": "bonob" }, + responseType: "arraybuffer", + } + ); + + private getTrack = (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/getSong", { + id, + }) + .then((it) => it.song) + .then((song) => + this.getAlbum(song.albumId!).then((album) => + asTrack(album, song) + ) + ); + + private toAlbumSummary = (albumList: album[]): AlbumSummary[] => + albumList.map((album) => ({ + id: album.id, + name: album.name, + year: album.year, + genre: maybeAsGenre(album.genre), + artistId: album.artistId, + artistName: album.artist, + coverArt: coverArtURN(album.coverArt), + })); + + private search3 = (q: any) => + this.subsonic + .getJSON(this.credentials, "/rest/search3", { + artistCount: 0, + albumCount: 0, + songCount: 0, + ...q, + }) + .then((it) => ({ + artists: it.searchResult3.artist || [], + albums: it.searchResult3.album || [], + songs: it.searchResult3.song || [], + })); + + private getAlbumList2 = (q: AlbumQuery) => + Promise.all([ + this.getArtists().then((it) => + inject(it, (total, artist) => total + artist.albumCount, 0) + ), + this.subsonic + .getJSON(this.credentials, "/rest/getAlbumList2", { + type: AlbumQueryTypeToSubsonicType[q.type], + ...(q.genre ? { genre: b64Decode(q.genre) } : {}), + size: 500, + offset: q._index, + }) + .then((response) => response.albumList2.album || []) + .then(this.toAlbumSummary), + ]).then(([total, albums]) => ({ + results: albums.slice(0, q._count), + total: albums.length == 500 ? total : (q._index || 0) + albums.length, + })); +} \ No newline at end of file diff --git a/tests/builders.ts b/tests/builders.ts index 83c1c7e..d3695de 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -17,7 +17,7 @@ import { } from "../src/music_service"; import { b64Encode } from "../src/b64"; -import { artistImageURN } from "../src/subsonic"; +import { artistImageURN } from "../src/subsonic/generic"; const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max)); const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`; diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 8dddb0c..94edbbf 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -2,19 +2,13 @@ import { Md5 } from "ts-md5/dist/md5"; import { v4 as uuid } from "uuid"; import { pipe } from "fp-ts/lib/function"; -import { option as O, taskEither as TE, task as T, either as E } from "fp-ts"; +import { taskEither as TE, task as T, either as E } from "fp-ts"; import { - isValidImage, Subsonic, t, DODGY_IMAGE_NAME, - asGenre, appendMimeTypeToClientFor, - asTrack, - artistImageURN, - images, - song, PingResponse, parseToken, asToken, @@ -30,34 +24,13 @@ import randomstring from "randomstring"; jest.mock("randomstring"); import { - Album, - Artist, - albumToAlbumSummary, - asArtistAlbumPairs, - Track, - AlbumSummary, - artistToArtistSummary, - AlbumQuery, - PlaylistSummary, - Playlist, - SimilarArtist, - Rating, AuthFailure, } from "../src/music_service"; import { - aGenre, - anAlbum, - anArtist, - aPlaylist, - aPlaylistSummary, - aSimilarArtist, aTrack, - POP, - ROCK, } from "./builders"; -import { b64Encode } from "../src/b64"; -import { BUrn } from "../src/burn"; import { asURLSearchParams } from "../src/utils"; +import { artistImageURN } from "../src/subsonic/generic"; describe("t", () => { it("should be an md5 of the password and the salt", () => { @@ -67,26 +40,6 @@ describe("t", () => { }); }); -describe("isValidImage", () => { - describe("when ends with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { - it("is dodgy", () => { - expect( - isValidImage("http://something/2a96cbd8b46e442fc41c2b86b821562f.png") - ).toEqual(false); - }); - }); - describe("when does not end with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { - it("is dodgy", () => { - expect(isValidImage("http://something/somethingelse.png")).toEqual(true); - expect( - isValidImage( - "http://something/2a96cbd8b46e442fc41c2b86b821562f.png?withsomequerystring=true" - ) - ).toEqual(true); - }); - }); -}); - describe("appendMimeTypeToUserAgentFor", () => { describe("when empty array", () => { it("should return bonob", () => { @@ -121,142 +74,12 @@ describe("appendMimeTypeToUserAgentFor", () => { }); }); -const ok = (data: string | object) => ({ +export const ok = (data: string | object) => ({ status: 200, data, }); -const asSimilarArtistJson = (similarArtist: SimilarArtist) => { - if (similarArtist.inLibrary) - return { - id: similarArtist.id, - name: similarArtist.name, - albumCount: 3, - }; - else - return { - id: -1, - name: similarArtist.name, - albumCount: 3, - }; -}; - -const getArtistInfoJson = ( - artist: Artist, - images: images = { - smallImageUrl: undefined, - mediumImageUrl: undefined, - largeImageUrl: undefined, - } -) => - subsonicOK({ - artistInfo2: { - ...images, - similarArtist: artist.similarArtists.map(asSimilarArtistJson), - }, - }); - -const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => pipe( - coverArt, - O.fromNullable, - O.map(it => it.resource.split(":")[1]), - O.getOrElseW(() => "") -) - -const asAlbumJson = ( - artist: { id: string | undefined; name: string | undefined }, - album: AlbumSummary, - tracks: Track[] = [] -) => ({ - id: album.id, - parent: artist.id, - isDir: "true", - title: album.name, - name: album.name, - album: album.name, - artist: artist.name, - genre: album.genre?.name, - coverArt: maybeIdFromCoverArtUrn(album.coverArt), - duration: "123", - playCount: "4", - year: album.year, - created: "2021-01-07T08:19:55.834207205Z", - artistId: artist.id, - songCount: "19", - isVideo: false, - song: tracks.map(asSongJson), -}); - -const asSongJson = (track: Track) => ({ - id: track.id, - parent: track.album.id, - title: track.name, - album: track.album.name, - artist: track.artist.name, - track: track.number, - genre: track.genre?.name, - isDir: "false", - coverArt: maybeIdFromCoverArtUrn(track.coverArt), - created: "2004-11-08T23:36:11", - duration: track.duration, - bitRate: 128, - size: "5624132", - suffix: "mp3", - contentType: track.mimeType, - isVideo: "false", - path: "ACDC/High voltage/ACDC - The Jack.mp3", - albumId: track.album.id, - artistId: track.artist.id, - type: "music", - starred: track.rating.love ? "sometime" : undefined, - userRating: track.rating.stars, - year: "", -}); - -const getAlbumListJson = (albums: [Artist, Album][]) => - subsonicOK({ - albumList2: { - album: albums.map(([artist, album]) => asAlbumJson(artist, album)), - }, - }); - -type ArtistExtras = { artistImageUrl: string | undefined } - -const asArtistJson = ( - artist: Artist, - extras: ArtistExtras = { artistImageUrl: undefined } -) => ({ - id: artist.id, - name: artist.name, - albumCount: artist.albums.length, - album: artist.albums.map((it) => asAlbumJson(artist, it)), - ...extras, -}); - -const getArtistJson = (artist: Artist, extras: ArtistExtras = { artistImageUrl: undefined }) => - subsonicOK({ - artist: asArtistJson(artist, extras), - }); - -const asGenreJson = (genre: { name: string; albumCount: number }) => ({ - songCount: 1475, - albumCount: genre.albumCount, - value: genre.name, -}); - -const getGenresJson = (genres: { name: string; albumCount: number }[]) => - subsonicOK({ - genres: { - genre: genres.map(asGenreJson), - }, - }); - -const getAlbumJson = (artist: Artist, album: Album, tracks: Track[]) => - subsonicOK({ album: asAlbumJson(artist, album, tracks) }); - -const getSongJson = (track: Track) => subsonicOK({ song: asSongJson(track) }); - -const subsonicOK = (body: any = {}) => ({ +export const subsonicOK = (body: any = {}) => ({ "subsonic-response": { status: "ok", version: "1.16.1", @@ -266,151 +89,8 @@ const subsonicOK = (body: any = {}) => ({ }, }); -const getSimilarSongsJson = (tracks: Track[]) => - subsonicOK({ similarSongs2: { song: tracks.map(asSongJson) } }); -const getTopSongsJson = (tracks: Track[]) => - subsonicOK({ topSongs: { song: tracks.map(asSongJson) } }); - -export type ArtistWithAlbum = { - artist: Artist; - album: Album; -}; - -const asPlaylistJson = (playlist: PlaylistSummary) => ({ - id: playlist.id, - name: playlist.name, - songCount: 1, - duration: 190, - public: true, - owner: "bob", - created: "2021-05-06T02:07:24.308007023Z", - changed: "2021-05-06T02:08:06Z", -}); - -const getPlayListsJson = (playlists: PlaylistSummary[]) => - subsonicOK({ - playlists: { - playlist: playlists.map(asPlaylistJson), - }, - }); - -const createPlayListJson = (playlist: PlaylistSummary) => - subsonicOK({ - playlist: asPlaylistJson(playlist), - }); - -const getPlayListJson = (playlist: Playlist) => - subsonicOK({ - playlist: { - id: playlist.id, - name: playlist.name, - songCount: playlist.entries.length, - duration: 627, - public: true, - owner: "bob", - created: "2021-05-06T02:07:30.460465988Z", - changed: "2021-05-06T02:40:04Z", - entry: playlist.entries.map((it) => ({ - id: it.id, - parent: "...", - isDir: false, - title: it.name, - album: it.album.name, - artist: it.artist.name, - track: it.number, - year: it.album.year, - genre: it.album.genre?.name, - coverArt: maybeIdFromCoverArtUrn(it.coverArt), - size: 123, - contentType: it.mimeType, - suffix: "mp3", - duration: it.duration, - bitRate: 128, - path: "...", - discNumber: 1, - created: "2019-09-04T04:07:00.138169924Z", - albumId: it.album.id, - artistId: it.artist.id, - type: "music", - isVideo: false, - starred: it.rating.love ? "sometime" : undefined, - userRating: it.rating.stars, - })), - }, - }); - -const getSearchResult3Json = ({ - artists, - albums, - tracks, -}: Partial<{ - artists: Artist[]; - albums: ArtistWithAlbum[]; - tracks: Track[]; -}>) => - subsonicOK({ - searchResult3: { - artist: (artists || []).map((it) => asArtistJson({ ...it, albums: [] })), - album: (albums || []).map((it) => asAlbumJson(it.artist, it.album, [])), - song: (tracks || []).map((it) => asSongJson(it)), - }, - }); - -const asArtistsJson = (artists: Artist[]) => { - const as: Artist[] = []; - const bs: Artist[] = []; - const cs: Artist[] = []; - const rest: Artist[] = []; - artists.forEach((it) => { - const firstChar = it.name.toLowerCase()[0]; - switch (firstChar) { - case "a": - as.push(it); - break; - case "b": - bs.push(it); - break; - case "c": - cs.push(it); - break; - default: - rest.push(it); - break; - } - }); - - const asArtistSummary = (artist: Artist) => ({ - id: artist.id, - name: artist.name, - albumCount: artist.albums.length, - }); - - return subsonicOK({ - artists: { - index: [ - { - name: "A", - artist: as.map(asArtistSummary), - }, - { - name: "B", - artist: bs.map(asArtistSummary), - }, - { - name: "C", - artist: cs.map(asArtistSummary), - }, - { - name: "D-Z", - artist: rest.map(asArtistSummary), - }, - ], - }, - }); -}; - -const error = (code: string, message: string) => ({ +export const error = (code: string, message: string) => ({ "subsonic-response": { status: "failed", version: "1.16.1", @@ -420,7 +100,7 @@ const error = (code: string, message: string) => ({ }, }); -const EMPTY = { +export const EMPTY = { "subsonic-response": { status: "ok", version: "1.16.1", @@ -429,7 +109,7 @@ const EMPTY = { }, }; -const FAILURE = { +export const FAILURE = { "subsonic-response": { status: "failed", version: "1.16.1", @@ -439,8 +119,6 @@ const FAILURE = { }, }; - - const pingJson = (pingResponse: Partial = {}) => ({ "subsonic-response": { status: "ok", @@ -525,117 +203,6 @@ describe("artistSummaryFromNDArtist", () => { }); }); -describe("artistURN", () => { - describe("when artist URL is", () => { - describe("a valid external URL", () => { - it("should return an external URN", () => { - expect( - artistImageURN({ artistId: "someArtistId", artistImageURL: "http://example.com/image.jpg" }) - ).toEqual({ system: "external", resource: "http://example.com/image.jpg" }); - }); - }); - - describe("an invalid external URL", () => { - describe("and artistId is valid", () => { - it("should return an external URN", () => { - expect( - artistImageURN({ - artistId: "someArtistId", - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` - }) - ).toEqual({ system: "subsonic", resource: "art:someArtistId" }); - }); - }); - - describe("and artistId is -1", () => { - it("should return an error icon urn", () => { - expect( - artistImageURN({ - artistId: "-1", - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` - }) - ).toBeUndefined(); - }); - }); - - describe("and artistId is undefined", () => { - it("should return an error icon urn", () => { - expect( - artistImageURN({ - artistId: undefined, - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` - }) - ).toBeUndefined(); - }); - }); - }); - - describe("undefined", () => { - describe("and artistId is valid", () => { - it("should return artist art by artist id URN", () => { - expect(artistImageURN({ artistId: "someArtistId", artistImageURL: undefined })).toEqual({system:"subsonic", resource:"art:someArtistId"}); - }); - }); - - describe("and artistId is -1", () => { - it("should return error icon", () => { - expect(artistImageURN({ artistId: "-1", artistImageURL: undefined })).toBeUndefined(); - }); - }); - - describe("and artistId is undefined", () => { - it("should return error icon", () => { - expect(artistImageURN({ artistId: undefined, artistImageURL: undefined })).toBeUndefined(); - }); - }); - }); - }); -}); - -describe("asTrack", () => { - describe("when the song has no artistId", () => { - const album = anAlbum(); - const track = aTrack({ artist: { id: undefined, name: "Not in library so no id", image: undefined }}); - - it("should provide no artistId", () => { - const result = asTrack(album, { ...asSongJson(track) }); - expect(result.artist.id).toBeUndefined(); - expect(result.artist.name).toEqual("Not in library so no id"); - expect(result.artist.image).toBeUndefined(); - }); - }); - - describe("when the song has no artist name", () => { - const album = anAlbum(); - - it("should provide a ? to sonos", () => { - const result = asTrack(album, { id: '1' } as any as song); - expect(result.artist.id).toBeUndefined(); - expect(result.artist.name).toEqual("?"); - expect(result.artist.image).toBeUndefined(); - }); - }); - - describe("invalid rating.stars values", () => { - const album = anAlbum(); - const track = aTrack(); - - describe("a value greater than 5", () => { - it("should be returned as 0", () => { - const result = asTrack(album, { ...asSongJson(track), userRating: 6 }); - expect(result.rating.stars).toEqual(0); - }); - }); - - describe("a value less than 0", () => { - it("should be returned as 0", () => { - const result = asTrack(album, { ...asSongJson(track), userRating: -1 }); - expect(result.rating.stars).toEqual(0); - }); - }); - }); -}); - describe("Subsonic", () => { const url = "http://127.0.0.22:4567"; const username = `user1-${uuid()}`; @@ -691,9 +258,6 @@ describe("Subsonic", () => { TE.fold(e => { throw e }, T.of) ) - const login = (credentials: Partial = {}) => tokenFor(credentials)() - .then((it) => subsonic.login(it.serviceToken)) - describe("generateToken", () => { describe("when the credentials are valid", () => { describe("when the backend is generic subsonic", () => { @@ -882,4017 +446,4 @@ describe("Subsonic", () => { }); }); - describe("getting genres", () => { - describe("when there are none", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(getGenresJson([])))); - }); - - it("should return empty array", async () => { - const result = await login({ username, password }) - .then((it) => it.genres()); - - expect(result).toEqual([]); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when there is only 1 that has an albumCount > 0", () => { - const genres = [ - { name: "genre1", albumCount: 1 }, - { name: "genreWithNoAlbums", albumCount: 0 }, - ]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getGenresJson(genres))) - ); - }); - - it("should return them alphabetically sorted", async () => { - const result = await login({ username, password }) - .then((it) => it.genres()); - - expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when there are many that have an albumCount > 0", () => { - const genres = [ - { name: "g1", albumCount: 1 }, - { name: "g2", albumCount: 1 }, - { name: "g3", albumCount: 1 }, - { name: "g4", albumCount: 1 }, - { name: "someGenreWithNoAlbums", albumCount: 0 }, - ]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getGenresJson(genres))) - ); - }); - - it("should return them alphabetically sorted", async () => { - const result = await login({ username, password }) - .then((it) => it.genres()); - - expect(result).toEqual([ - { id: b64Encode("g1"), name: "g1" }, - { id: b64Encode("g2"), name: "g2" }, - { id: b64Encode("g3"), name: "g3" }, - { id: b64Encode("g4"), name: "g4" }, - ]); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - }); - - describe("getting an artist", () => { - describe("when the artist exists", () => { - describe("and has many similar artists", () => { - const album1: Album = anAlbum({ genre: asGenre("Pop") }); - - const album2: Album = anAlbum({ genre: asGenre("Pop") }); - - const artist: Artist = anArtist({ - albums: [album1, album2], - similarArtists: [ - aSimilarArtist({ - id: "similar1.id", - name: "similar1", - inLibrary: true, - }), - aSimilarArtist({ id: "-1", name: "similar2", inLibrary: false }), - aSimilarArtist({ - id: "similar3.id", - name: "similar3", - inLibrary: true, - }), - aSimilarArtist({ id: "-1", name: "similar4", inLibrary: false }), - ], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return the similar artists", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: `${artist.id}`, - name: artist.name, - image: { system:"subsonic", resource:`art:${artist.id}` }, - albums: artist.albums, - similarArtists: artist.similarArtists, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has one similar artist", () => { - const album1: Album = anAlbum({ genre: asGenre("G1") }); - - const album2: Album = anAlbum({ genre: asGenre("G2") }); - - const artist: Artist = anArtist({ - albums: [album1, album2], - similarArtists: [ - aSimilarArtist({ - id: "similar1.id", - name: "similar1", - inLibrary: true, - }), - ], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return the similar artists", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system:"subsonic", resource:`art:${artist.id}` }, - albums: artist.albums, - similarArtists: artist.similarArtists, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has no similar artists", () => { - const album1: Album = anAlbum({ genre: asGenre("Jock") }); - - const album2: Album = anAlbum({ genre: asGenre("Mock") }); - - const artist: Artist = anArtist({ - albums: [album1, album2], - similarArtists: [], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return the similar artists", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system:"subsonic", resource: `art:${artist.id}` }, - albums: artist.albums, - similarArtists: artist.similarArtists, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has dodgy looking artist image uris", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl}))) - ); - }); - - it("should return remove the dodgy looking image uris and return urn for artist:id", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { - system: "subsonic", - resource: `art:${artist.id}`, - }, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has a good external image uri from getArtist route", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: 'http://example.com:1234/good/looking/image.png' }))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl }))) - ); - }); - - it("should use the external url", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system: "external", resource: 'http://example.com:1234/good/looking/image.png' }, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has a good large external image uri from getArtistInfo route", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: 'http://example.com:1234/good/large/image.png' }))) - ); - }); - - it("should use the external url", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system: "external", resource: 'http://example.com:1234/good/large/image.png' }, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - - describe("and has a good medium external image uri from getArtistInfo route", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: 'http://example.com:1234/good/medium/image.png', largeImageUrl: dodgyImageUrl }))) - ); - }); - - it("should use the external url", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system:"external", resource: 'http://example.com:1234/good/medium/image.png' }, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has multiple albums", () => { - const album1: Album = anAlbum({ genre: asGenre("Pop") }); - - const album2: Album = anAlbum({ genre: asGenre("Flop") }); - - const artist: Artist = anArtist({ - albums: [album1, album2], - similarArtists: [], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return it", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: artist.image, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has only 1 album", () => { - const album: Album = anAlbum({ genre: asGenre("Pop") }); - - const artist: Artist = anArtist({ - albums: [album], - similarArtists: [], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return it", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: artist.image, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has no albums", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return it", async () => { - const result: Artist = await login({ username, password }) - .then((it) => it.artist(artist.id!)); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: artist.image, - albums: [], - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - }); - }); - - describe("getting artists", () => { - describe("when subsonic flavour is generic", () => { - describe("when there are indexes, but no artists", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - subsonicOK({ - artists: { - index: [ - { - name: "#", - }, - { - name: "A", - }, - { - name: "B", - }, - ], - }, - }) - ) - ) - ); - }); - - it("should return empty", async () => { - const artists = await login({ username, password }) - .then((it) => it.artists({ _index: 0, _count: 100 })); - - expect(artists).toEqual({ - results: [], - total: 0, - }); - }); - }); - - describe("when there no indexes and no artists", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - subsonicOK({ - artists: {}, - }) - ) - ) - ); - }); - - it("should return empty", async () => { - const artists = await login({ username, password }) - .then((it) => it.artists({ _index: 0, _count: 100 })); - - expect(artists).toEqual({ - results: [], - total: 0, - }); - }); - }); - - describe("when there is one index and one artist", () => { - const artist1 = anArtist({albums:[anAlbum(), anAlbum(), anAlbum(), anAlbum()]}); - - const asArtistsJson = subsonicOK({ - artists: { - index: [ - { - name: "#", - artist: [ - { - id: artist1.id, - name: artist1.name, - albumCount: artist1.albums.length, - }, - ], - }, - ], - }, - }); - - describe("when it all fits on one page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson))); - }); - - it("should return the single artist", async () => { - const artists = await login({ username, password }) - .then((it) => it.artists({ _index: 0, _count: 100 })); - - const expectedResults = [{ - id: artist1.id, - image: artist1.image, - name: artist1.name, - sortName: artist1.name - }]; - - expect(artists).toEqual({ - results: expectedResults, - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - }); - - describe("when there are artists", () => { - const artist1 = anArtist({ name: "A Artist", albums:[anAlbum()] }); - const artist2 = anArtist({ name: "B Artist" }); - const artist3 = anArtist({ name: "C Artist" }); - const artist4 = anArtist({ name: "D Artist" }); - const artists = [artist1, artist2, artist3, artist4]; - - describe("when no paging is in effect", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ); - }); - - it("should return all the artists", async () => { - const artists = await login({ username, password }) - .then((it) => it.artists({ _index: 0, _count: 100 })); - - const expectedResults = [artist1, artist2, artist3, artist4].map( - (it) => ({ - id: it.id, - image: it.image, - name: it.name, - sortName: it.name, - }) - ); - - expect(artists).toEqual({ - results: expectedResults, - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when paging specified", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ); - }); - - it("should return only the correct page of artists", async () => { - const artists = await login({ username, password }) - .then((it) => it.artists({ _index: 1, _count: 2 })); - - const expectedResults = [artist2, artist3].map((it) => ({ - id: it.id, - image: it.image, - name: it.name, - sortName: it.name, - })); - - expect(artists).toEqual({ results: expectedResults, total: 4 }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - }); - }); - - describe("when the subsonic type is navidrome", () => { - const ndArtist1 = { - id: uuid(), - name: "Artist1", - orderArtistName: "Artist1", - largeImageUrl: "http://example.com/artist1/image.jpg" - }; - const ndArtist2 = { - id: uuid(), - name: "Artist2", - orderArtistName: "The Artist2", - largeImageUrl: undefined - }; - const ndArtist3 = { - id: uuid(), - name: "Artist3", - orderArtistName: "An Artist3", - largeImageUrl: `http://example.com/artist3/${DODGY_IMAGE_NAME}` - }; - const ndArtist4 = { - id: uuid(), - name: "Artist4", - orderArtistName: "An Artist4", - largeImageUrl: `http://example.com/artist4/${DODGY_IMAGE_NAME}` - }; - const bearer = `bearer-${uuid()}`; - - describe("when no paging is specified", () => { - beforeEach(() => { - (axios.get as jest.Mock) - .mockImplementationOnce(() => Promise.resolve(ok(pingJson({ type: "navidrome" })))) - .mockImplementationOnce(() => - Promise.resolve({ - status: 200, - data: [ - ndArtist1, - ndArtist2, - ndArtist3, - ndArtist4, - ], - headers: { - "x-total-count": "4" - } - }) - ); - - (axios.post as jest.Mock).mockResolvedValue(ok({ token: bearer })); - }); - - it("should fetch all artists", async () => { - const artists = await login({ username, password, bearer, type: "navidrome" }) - .then((it) => it.artists({ _index: undefined, _count: undefined })); - - expect(artists).toEqual({ - results: [ - artistSummaryFromNDArtist(ndArtist1), - artistSummaryFromNDArtist(ndArtist2), - artistSummaryFromNDArtist(ndArtist3), - artistSummaryFromNDArtist(ndArtist4), - ], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/api/artist`, { - params: asURLSearchParams({ - _sort: "name", - _order: "ASC", - _start: "0" - }), - headers: { - "User-Agent": "bonob", - "x-nd-authorization": `Bearer ${bearer}`, - }, - }); - }); - }); - - describe("when start index is specified", () => { - beforeEach(() => { - (axios.get as jest.Mock) - .mockImplementationOnce(() => Promise.resolve(ok(pingJson({ type: "navidrome" })))) - .mockImplementationOnce(() => - Promise.resolve({ - status: 200, - data: [ - ndArtist3, - ndArtist4, - ], - headers: { - "x-total-count": "5" - } - }) - ); - - (axios.post as jest.Mock).mockResolvedValue(ok({ token: bearer })); - }); - - it("should fetch all artists", async () => { - const artists = await login({ username, password, bearer, type: "navidrome" }) - .then((it) => it.artists({ _index: 2, _count: undefined })); - - expect(artists).toEqual({ - results: [ - artistSummaryFromNDArtist(ndArtist3), - artistSummaryFromNDArtist(ndArtist4), - ], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/api/artist`, { - params: asURLSearchParams({ - _sort: "name", - _order: "ASC", - _start: "2" - }), - headers: { - "User-Agent": "bonob", - "x-nd-authorization": `Bearer ${bearer}`, - }, - }); - }); - }); - - describe("when start index and count is specified", () => { - beforeEach(() => { - (axios.get as jest.Mock) - .mockImplementationOnce(() => Promise.resolve(ok(pingJson({ type: "navidrome" })))) - .mockImplementationOnce(() => - Promise.resolve({ - status: 200, - data: [ - ndArtist3, - ndArtist4, - ], - headers: { - "x-total-count": "5" - } - }) - ); - - (axios.post as jest.Mock).mockResolvedValue(ok({ token: bearer })); - }); - - it("should fetch all artists", async () => { - const artists = await login({ username, password, bearer, type: "navidrome" }) - .then((it) => it.artists({ _index: 2, _count: 23 })); - - expect(artists).toEqual({ - results: [ - artistSummaryFromNDArtist(ndArtist3), - artistSummaryFromNDArtist(ndArtist4), - ], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/api/artist`, { - params: asURLSearchParams({ - _sort: "name", - _order: "ASC", - _start: "2", - _end: "25" - }), - headers: { - "User-Agent": "bonob", - "x-nd-authorization": `Bearer ${bearer}`, - }, - }); - }); - }); - - }); - }); - - describe("getting albums", () => { - describe("filtering", () => { - const album1 = anAlbum({ id: "album1", genre: asGenre("Pop") }); - const album2 = anAlbum({ id: "album2", genre: asGenre("Rock") }); - const album3 = anAlbum({ id: "album3", genre: asGenre("Pop") }); - const album4 = anAlbum({ id: "album4", genre: asGenre("Pop") }); - const album5 = anAlbum({ id: "album5", genre: asGenre("Pop") }); - - const artist = anArtist({ - albums: [album1, album2, album3, album4, album5], - }); - - describe("by genre", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist, album1], - // album2 is not Pop - [artist, album3], - ]) - ) - ) - ); - }); - - it("should map the 64 encoded genre back into the subsonic genre", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - genre: b64Encode("Pop"), - type: "byGenre", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album1, album3].map(albumToAlbumSummary), - total: 2, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "byGenre", - genre: "Pop", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("by newest", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist, album3], - [artist, album2], - [artist, album1], - ]) - ) - ) - ); - }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "recentlyAdded", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album3, album2, album1].map(albumToAlbumSummary), - total: 3, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "newest", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("by recently played", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist, album3], - [artist, album2], - // album1 never played - ]) - ) - ) - ); - }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "recentlyPlayed", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album3, album2].map(albumToAlbumSummary), - total: 2, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "recent", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("by frequently played", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce( - () => - // album1 never played - Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) - // album3 never played - ); - }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "mostPlayed" }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album2].map(albumToAlbumSummary), - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "frequent", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("by starred", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce( - () => - // album1 never played - Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) - // album3 never played - ); - }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "starred" }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album2].map(albumToAlbumSummary), - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "highest", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - }); - - describe("when the artist has only 1 album", () => { - const artist = anArtist({ - name: "one hit wonder", - albums: [anAlbum({ genre: asGenre("Pop") })], - }); - const artists = [artist]; - const albums = artists.flatMap((artist) => artist.albums); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) - ); - }); - - it("should return the album", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: albums, - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("when the only artist has no albums", () => { - const artist = anArtist({ - name: "no hit wonder", - albums: [], - }); - const artists = [artist]; - const albums = artists.flatMap((artist) => artist.albums); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) - ); - }); - - it("should return the album", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: albums, - total: 0, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("when there are 6 albums in total", () => { - const genre1 = asGenre("genre1"); - const genre2 = asGenre("genre2"); - const genre3 = asGenre("genre3"); - - const artist1 = anArtist({ - name: "abba", - albums: [ - anAlbum({ name: "album1", genre: genre1 }), - anAlbum({ name: "album2", genre: genre2 }), - anAlbum({ name: "album3", genre: genre3 }), - ], - }); - const artist2 = anArtist({ - name: "babba", - albums: [ - anAlbum({ name: "album4", genre: genre1 }), - anAlbum({ name: "album5", genre: genre2 }), - anAlbum({ name: "album6", genre: genre3 }), - ], - }); - const artists = [artist1, artist2]; - const albums = artists.flatMap((artist) => artist.albums); - - describe("querying for all of them", () => { - it("should return all of them with corrent paging information", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) - ); - - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: albums, - total: 6, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("querying for a page of them", () => { - it("should return the page with the corrent paging information", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, artist1.albums[2]!], - [artist2, artist2.albums[0]!], - // due to pre-fetch will get next 2 albums also - [artist2, artist2.albums[1]!], - [artist2, artist2.albums[2]!], - ]) - ) - ) - ); - - const q: AlbumQuery = { - _index: 2, - _count: 2, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [artist1.albums[2], artist2.albums[0]], - total: 6, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 2, - }), - headers, - }); - }); - }); - }); - - describe("when the number of albums reported by getArtists does not match that of getAlbums", () => { - const genre = asGenre("lofi"); - - const album1 = anAlbum({ name: "album1", genre }); - const album2 = anAlbum({ name: "album2", genre }); - const album3 = anAlbum({ name: "album3", genre }); - const album4 = anAlbum({ name: "album4", genre }); - const album5 = anAlbum({ name: "album5", genre }); - - // the artists have 5 albums in the getArtists endpoint - const artist1 = anArtist({ - albums: [album1, album2, album3, album4], - }); - const artist2 = anArtist({ - albums: [album5], - }); - const artists = [artist1, artist2]; - - describe("when the number of albums returned from getAlbums is less the number of albums in the getArtists endpoint", () => { - describe("when the query comes back on 1 page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album1], - [artist1, album2], - [artist1, album3], - // album4 is missing from the albums end point for some reason - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should return the page of albums, updating the total to be accurate", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album1, album2, album3, album5], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - - describe("when the query is for the first page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album1], - [artist1, album2], - // album3 & album5 is returned due to the prefetch - [artist1, album3], - // album4 is missing from the albums end point for some reason - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should filter out the pre-fetched albums", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 2, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album1, album2], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - - describe("when the query is for the last page only", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - // album1 is on the first page - // album2 is on the first page - [artist1, album3], - // album4 is missing from the albums end point for some reason - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should return the last page of albums, updating the total to be accurate", async () => { - const q: AlbumQuery = { - _index: 2, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album3, album5], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - }); - - describe("when the number of albums returned from getAlbums is more than the number of albums in the getArtists endpoint", () => { - describe("when the query comes back on 1 page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - asArtistsJson([ - // artist1 has lost 2 albums on the getArtists end point - { ...artist1, albums: [album1, album2] }, - artist2, - ]) - ) - ) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album1], - [artist1, album2], - [artist1, album3], - [artist1, album4], - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should return the page of albums, updating the total to be accurate", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album1, album2, album3, album4, album5], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - - describe("when the query is for the first page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - asArtistsJson([ - // artist1 has lost 2 albums on the getArtists end point - { ...artist1, albums: [album1, album2] }, - artist2, - ]) - ) - ) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album1], - [artist1, album2], - [artist1, album3], - [artist1, album4], - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should filter out the pre-fetched albums", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 2, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album1, album2], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - - describe("when the query is for the last page only", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - asArtistsJson([ - // artist1 has lost 2 albums on the getArtists end point - { ...artist1, albums: [album1, album2] }, - artist2, - ]) - ) - ) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album3], - [artist1, album4], - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should return the last page of albums, updating the total to be accurate", async () => { - const q: AlbumQuery = { - _index: 2, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await login({ username, password }) - .then((it) => it.albums(q)); - - expect(result).toEqual({ - results: [album3, album4, album5], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getAlbumList2`, - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - }); - }); - }); - - describe("getting an album", () => { - describe("when it exists", () => { - const genre = asGenre("Pop"); - - const album = anAlbum({ genre }); - - const artist = anArtist({ albums: [album] }); - - const tracks = [ - aTrack({ artist, album, genre }), - aTrack({ artist, album, genre }), - aTrack({ artist, album, genre }), - aTrack({ artist, album, genre }), - ]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should return the album", async () => { - const result = await login({ username, password }) - .then((it) => it.album(album.id)); - - expect(result).toEqual(album); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - }); - - describe("getting tracks", () => { - describe("for an album", () => { - describe("when the album has multiple tracks, some of which are rated", () => { - const hipHop = asGenre("Hip-Hop"); - const tripHop = asGenre("Trip-Hop"); - - const album = anAlbum({ id: "album1", name: "Burnin", genre: hipHop }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - const track1 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: hipHop, - rating: { - love: true, - stars: 3, - }, - }); - const track2 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: hipHop, - rating: { - love: false, - stars: 0, - }, - }); - const track3 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: tripHop, - rating: { - love: true, - stars: 5, - }, - }); - const track4 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: tripHop, - rating: { - love: false, - stars: 1, - }, - }); - - const tracks = [track1, track2, track3, track4]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should return the album", async () => { - const result = await login({ username, password }) - .then((it) => it.tracks(album.id)); - - expect(result).toEqual([track1, track2, track3, track4]); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - - describe("when the album has only 1 track", () => { - const flipFlop = asGenre("Flip-Flop"); - - const album = anAlbum({ - id: "album1", - name: "Burnin", - genre: flipFlop, - }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: flipFlop, - }); - - const tracks = [track]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should return the album", async () => { - const result = await login({ username, password }) - .then((it) => it.tracks(album.id)); - - expect(result).toEqual([track]); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - - describe("when the album has only no tracks", () => { - const album = anAlbum({ id: "album1", name: "Burnin" }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - const tracks: Track[] = []; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should empty array", async () => { - const result = await login({ username, password }) - .then((it) => it.tracks(album.id)); - - expect(result).toEqual([]); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - }); - - describe("a single track", () => { - const pop = asGenre("Pop"); - - const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - describe("that is starred", () => { - it("should return the track", async () => { - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: pop, - rating: { - love: true, - stars: 4, - }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.track(track.id)); - - expect(result).toEqual({ - ...track, - rating: { love: true, stars: 4 }, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: track.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - - describe("that is not starred", () => { - it("should return the track", async () => { - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: pop, - rating: { - love: false, - stars: 0, - }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.track(track.id)); - - expect(result).toEqual({ - ...track, - rating: { love: false, stars: 0 }, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: track.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - }); - }); - - describe("streaming a track", () => { - const trackId = uuid(); - const genre = aGenre("foo"); - - const album = anAlbum({ genre }); - const artist = anArtist({ - albums: [album] - }); - const track = aTrack({ - id: trackId, - album: albumToAlbumSummary(album), - artist: artistToArtistSummary(artist), - genre, - }); - - describe("content-range, accept-ranges or content-length", () => { - beforeEach(() => { - streamClientApplication.mockReturnValue("bonob"); - }); - - describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => { - it("should return undefined values", async () => { - const stream = { - pipe: jest.fn(), - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.stream({ trackId, range: undefined })); - - expect(result.headers).toEqual({ - "content-type": "audio/mpeg", - "content-length": undefined, - "content-range": undefined, - "accept-ranges": undefined, - }); - }); - }); - - describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => { - it("should return undefined values", async () => { - const stream = { - pipe: jest.fn(), - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - "content-length": undefined, - "content-range": undefined, - "accept-ranges": undefined, - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.stream({ trackId, range: undefined })); - - expect(result.headers).toEqual({ - "content-type": "audio/mpeg", - "content-length": undefined, - "content-range": undefined, - "accept-ranges": undefined, - }); - }); - }); - - describe("with no range specified", () => { - describe("navidrome returns a 200", () => { - it("should return the content", async () => { - const stream = { - pipe: jest.fn(), - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - "content-length": "1667", - "content-range": "-200", - "accept-ranges": "bytes", - "some-other-header": "some-value", - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.stream({ trackId, range: undefined })); - - expect(result.headers).toEqual({ - "content-type": "audio/mpeg", - "content-length": "1667", - "content-range": "-200", - "accept-ranges": "bytes", - }); - expect(result.stream).toEqual(stream); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: asURLSearchParams({ - ...authParams, - id: trackId, - }), - headers: { - "User-Agent": "bonob", - }, - responseType: "stream", - }); - }); - }); - - describe("navidrome returns something other than a 200", () => { - it("should fail", async () => { - const trackId = "track123"; - - const streamResponse = { - status: 400, - headers: { - 'content-type': 'text/html', - 'content-length': '33' - } - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const musicLibrary = await login({ username, password }); - - return expect( - musicLibrary.stream({ trackId, range: undefined }) - ).rejects.toEqual(`Subsonic failed with a 400 status`); - }); - }); - - describe("io exception occurs", () => { - it("should fail", async () => { - const trackId = "track123"; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.reject("IO error occured")); - - const musicLibrary = await login({ username, password }); - - return expect( - musicLibrary.stream({ trackId, range: undefined }) - ).rejects.toEqual(`Subsonic failed with: IO error occured`); - }); - }); - }); - - describe("with range specified", () => { - it("should send the range to navidrome", async () => { - const stream = { - pipe: jest.fn(), - }; - - const range = "1000-2000"; - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/flac", - "content-length": "66", - "content-range": "100-200", - "accept-ranges": "none", - "some-other-header": "some-value", - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.stream({ trackId, range })); - - expect(result.headers).toEqual({ - "content-type": "audio/flac", - "content-length": "66", - "content-range": "100-200", - "accept-ranges": "none", - }); - expect(result.stream).toEqual(stream); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: asURLSearchParams({ - ...authParams, - id: trackId, - }), - headers: { - "User-Agent": "bonob", - Range: range, - }, - responseType: "stream", - }); - }); - }); - }); - - describe("when navidrome has a custom StreamClientApplication registered", () => { - describe("when no range specified", () => { - it("should user the custom StreamUserAgent when calling navidrome", async () => { - const clientApplication = `bonob-${uuid()}`; - streamClientApplication.mockReturnValue(clientApplication); - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - }, - data: Buffer.from("the track", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, [track]))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - await login({ username, password }) - .then((it) => it.stream({ trackId, range: undefined })); - - expect(streamClientApplication).toHaveBeenCalledWith(track); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: asURLSearchParams({ - ...authParams, - id: trackId, - c: clientApplication, - }), - headers: { - "User-Agent": "bonob", - }, - responseType: "stream", - }); - }); - }); - - describe("when range specified", () => { - it("should user the custom StreamUserAgent when calling navidrome", async () => { - const range = "1000-2000"; - const clientApplication = `bonob-${uuid()}`; - streamClientApplication.mockReturnValue(clientApplication); - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - }, - data: Buffer.from("the track", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, [track]))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - await login({ username, password }) - .then((it) => it.stream({ trackId, range })); - - expect(streamClientApplication).toHaveBeenCalledWith(track); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: asURLSearchParams({ - ...authParams, - id: trackId, - c: clientApplication, - }), - headers: { - "User-Agent": "bonob", - Range: range, - }, - responseType: "stream", - }); - }); - }); - }); - }); - - describe("fetching cover art", () => { - describe("fetching album art", () => { - describe("when no size is specified", () => { - it("should fetch the image", async () => { - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - const coverArtId = "someCoverArt"; - const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.coverArt(coverArtURN)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - }), - headers, - responseType: "arraybuffer", - }); - }); - }); - - describe("when size is specified", () => { - it("should fetch the image", async () => { - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - const coverArtId = uuid(); - const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` } - const size = 1879; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.coverArt(coverArtURN, size)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - size, - }), - headers, - responseType: "arraybuffer", - }); - }); - }); - - describe("when an unexpected error occurs", () => { - it("should return undefined", async () => { - const size = 1879; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.reject("BOOOM")); - - const result = await login({ username, password }) - .then((it) => it.coverArt({ system: "external", resource: "http://localhost:404" }, size)); - - expect(result).toBeUndefined(); - }); - }); - }); - - describe("fetching cover art", () => { - describe("when urn.resource is not subsonic", () => { - it("should be undefined", async () => { - const covertArtURN = { system: "notSubsonic", resource: `art:${uuid()}` }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))); - - const result = await login({ username, password }) - .then((it) => it.coverArt(covertArtURN, 190)); - - expect(result).toBeUndefined(); - }); - }); - - describe("when no size is specified", () => { - it("should fetch the image", async () => { - const coverArtId = uuid() - const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.coverArt(covertArtURN)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - }), - headers, - responseType: "arraybuffer", - } - ); - }); - - describe("and an error occurs fetching the uri", () => { - it("should return undefined", async () => { - const coverArtId = uuid() - const covertArtURN = { system:"subsonic", resource: `art:${coverArtId}` }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.reject("BOOOM")); - - const result = await login({ username, password }) - .then((it) => it.coverArt(covertArtURN)); - - expect(result).toBeUndefined(); - }); - }); - }); - - describe("when size is specified", () => { - const size = 189; - - it("should fetch the image", async () => { - const coverArtId = uuid() - const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await login({ username, password }) - .then((it) => it.coverArt(covertArtURN, size)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - size - }), - headers, - responseType: "arraybuffer", - } - ); - }); - - describe("and an error occurs fetching the uri", () => { - it("should return undefined", async () => { - const coverArtId = uuid() - const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.reject("BOOOM")); - - const result = await login({ username, password }) - .then((it) => it.coverArt(covertArtURN, size)); - - expect(result).toBeUndefined(); - }); - }); - }); - }); - }); - - describe("rate", () => { - const trackId = uuid(); - - const rate = (trackId: string, rating: Rating) => - login({ username, password }) - .then((it) => it.rate(trackId, rating)); - - const artist = anArtist(); - const album = anAlbum({ id: "album1", name: "Burnin", genre: POP }); - - describe("rating a track", () => { - describe("loving a track that isnt already loved", () => { - it("should mark the track as loved", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: false, stars: 0 }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await rate(trackId, { love: true, stars: 0 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/star`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - }), - headers, - }); - }); - }); - - describe("unloving a track that is loved", () => { - it("should mark the track as loved", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: true, stars: 0 }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await rate(trackId, { love: false, stars: 0 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/unstar`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - }), - headers, - }); - }); - }); - - describe("loving a track that is already loved", () => { - it("shouldn't do anything", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: true, stars: 0 }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await rate(trackId, { love: true, stars: 0 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledTimes(3); - }); - }); - - describe("rating a track with a different rating", () => { - it("should add the new rating", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: false, stars: 0 }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await rate(trackId, { love: false, stars: 3 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/setRating`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - rating: 3, - }), - headers, - }); - }); - }); - - describe("rating a track with the same rating it already has", () => { - it("shouldn't do anything", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: true, stars: 3 }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await rate(trackId, { love: true, stars: 3 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledTimes(3); - }); - }); - - describe("loving and rating a track", () => { - it("should return true", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: true, stars: 3 }, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await rate(trackId, { love: false, stars: 5 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/unstar`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - }), - headers, - }); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/setRating`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - rating: 5, - }), - headers, - }); - }); - }); - - describe("invalid star values", () => { - describe("stars of -1", () => { - it("should return false", async () => { - mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))); - - const result = await rate(trackId, { love: true, stars: -1 }); - expect(result).toEqual(false); - }); - }); - - describe("stars of 6", () => { - it("should return false", async () => { - mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))); - - const result = await rate(trackId, { love: true, stars: -1 }); - expect(result).toEqual(false); - }); - }); - }); - - describe("when fails", () => { - it("should return false", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(FAILURE))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await rate(trackId, { love: true, stars: 0 }); - - expect(result).toEqual(false); - }); - }); - }); - }); - - describe("scrobble", () => { - describe("when succeeds", () => { - it("should return true", async () => { - const id = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await login({ username, password }) - .then((it) => it.scrobble(id)); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - submission: true, - }), - headers, - }); - }); - }); - - describe("when fails", () => { - it("should return false", async () => { - const id = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve({ - status: 500, - data: {}, - }) - ); - - const result = await login({ username, password }) - .then((it) => it.scrobble(id)); - - expect(result).toEqual(false); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - submission: true, - }), - headers, - }); - }); - }); - }); - - describe("nowPlaying", () => { - describe("when succeeds", () => { - it("should return true", async () => { - const id = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await login({ username, password }) - .then((it) => it.nowPlaying(id)); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - submission: false, - }), - headers, - }); - }); - }); - - describe("when fails", () => { - it("should return false", async () => { - const id = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve({ - status: 500, - data: {}, - }) - ); - - const result = await login({ username, password }) - .then((it) => it.nowPlaying(id)); - - expect(result).toEqual(false); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - submission: false, - }), - headers, - }); - }); - }); - }); - - describe("searchArtists", () => { - describe("when there is 1 search results", () => { - it("should return true", async () => { - const artist1 = anArtist({ name: "foo woo" }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ artists: [artist1] }))) - ); - - const result = await login({ username, password }) - .then((it) => it.searchArtists("foo")); - - expect(result).toEqual([artistToArtistSummary(artist1)]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 20, - albumCount: 0, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - - describe("when there are many search results", () => { - it("should return true", async () => { - const artist1 = anArtist({ name: "foo woo" }); - const artist2 = anArtist({ name: "foo choo" }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok(getSearchResult3Json({ artists: [artist1, artist2] })) - ) - ); - - const result = await login({ username, password }) - .then((it) => it.searchArtists("foo")); - - expect(result).toEqual([ - artistToArtistSummary(artist1), - artistToArtistSummary(artist2), - ]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 20, - albumCount: 0, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - - describe("when there are no search results", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) - ); - - const result = await login({ username, password }) - .then((it) => it.searchArtists("foo")); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 20, - albumCount: 0, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - }); - - describe("searchAlbums", () => { - describe("when there is 1 search results", () => { - it("should return true", async () => { - const album = anAlbum({ - name: "foo woo", - genre: { id: b64Encode("pop"), name: "pop" }, - }); - const artist = anArtist({ name: "#1", albums: [album] }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok(getSearchResult3Json({ albums: [{ artist, album }] })) - ) - ); - - const result = await login({ username, password }) - .then((it) => it.searchAlbums("foo")); - - expect(result).toEqual([albumToAlbumSummary(album)]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 20, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - - describe("when there are many search results", () => { - it("should return true", async () => { - const album1 = anAlbum({ - name: "album1", - genre: { id: b64Encode("pop"), name: "pop" }, - }); - const artist1 = anArtist({ name: "artist1", albums: [album1] }); - - const album2 = anAlbum({ - name: "album2", - genre: { id: b64Encode("pop"), name: "pop" }, - }); - const artist2 = anArtist({ name: "artist2", albums: [album2] }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getSearchResult3Json({ - albums: [ - { artist: artist1, album: album1 }, - { artist: artist2, album: album2 }, - ], - }) - ) - ) - ); - - const result = await login({ username, password }) - .then((it) => it.searchAlbums("moo")); - - expect(result).toEqual([ - albumToAlbumSummary(album1), - albumToAlbumSummary(album2), - ]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 20, - songCount: 0, - query: "moo", - }), - headers, - }); - }); - }); - - describe("when there are no search results", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ albums: [] }))) - ); - - const result = await login({ username, password }) - .then((it) => it.searchAlbums("foo")); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 20, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - }); - - describe("searchSongs", () => { - describe("when there is 1 search results", () => { - it("should return true", async () => { - const pop = asGenre("Pop"); - - const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: pop, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ tracks: [track] }))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track)))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.searchTracks("foo")); - - expect(result).toEqual([track]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 0, - songCount: 20, - query: "foo", - }), - headers, - }); - }); - }); - - describe("when there are many search results", () => { - it("should return true", async () => { - const pop = asGenre("Pop"); - - const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - const artist1 = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album1], - }); - const track1 = aTrack({ - id: "track1", - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - genre: pop, - }); - - const album2 = anAlbum({ id: "album2", name: "Bobbin", genre: pop }); - const artist2 = anArtist({ - id: "artist2", - name: "Jane Marley", - albums: [album2], - }); - const track2 = aTrack({ - id: "track2", - artist: artistToArtistSummary(artist2), - album: albumToAlbumSummary(album2), - genre: pop, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getSearchResult3Json({ - tracks: [track1, track2], - }) - ) - ) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track1))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track2))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist2, album2, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.searchTracks("moo")); - - expect(result).toEqual([track1, track2]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 0, - songCount: 20, - query: "moo", - }), - headers, - }); - }); - }); - - describe("when there are no search results", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ tracks: [] }))) - ); - - const result = await login({ username, password }) - .then((it) => it.searchTracks("foo")); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 0, - songCount: 20, - query: "foo", - }), - headers, - }); - }); - }); - }); - - describe("playlists", () => { - describe("getting playlists", () => { - describe("when there is 1 playlist results", () => { - it("should return it", async () => { - const playlist = aPlaylistSummary(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson([playlist]))) - ); - - const result = await login({ username, password }) - .then((it) => it.playlists()); - - expect(result).toEqual([playlist]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when there are many playlists", () => { - it("should return them", async () => { - const playlist1 = aPlaylistSummary(); - const playlist2 = aPlaylistSummary(); - const playlist3 = aPlaylistSummary(); - const playlists = [playlist1, playlist2, playlist3]; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson(playlists))) - ); - - const result = await login({ username, password }) - .then((it) => it.playlists()); - - expect(result).toEqual(playlists); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when there are no playlists", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson([]))) - ); - - const result = await login({ username, password }) - .then((it) => it.playlists()); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - }); - - describe("getting a single playlist", () => { - describe("when there is no playlist with the id", () => { - it("should raise error", async () => { - const id = "id404"; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(error("70", "data not found"))) - ); - - return expect( - login({ username, password }) - .then((it) => it.playlist(id)) - ).rejects.toEqual("Subsonic error:data not found"); - }); - }); - - describe("when there is a playlist with the id", () => { - describe("and it has tracks", () => { - it("should return the playlist with entries", async () => { - const id = uuid(); - const name = "Great Playlist"; - const artist1 = anArtist(); - const album1 = anAlbum({ - artistId: artist1.id, - artistName: artist1.name, - genre: POP, - }); - const track1 = aTrack({ - genre: POP, - number: 66, - coverArt: album1.coverArt, - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - }); - - const artist2 = anArtist(); - const album2 = anAlbum({ - artistId: artist2.id, - artistName: artist2.name, - genre: ROCK, - }); - const track2 = aTrack({ - genre: ROCK, - number: 77, - coverArt: album2.coverArt, - artist: artistToArtistSummary(artist2), - album: albumToAlbumSummary(album2), - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getPlayListJson({ - id, - name, - entries: [track1, track2], - }) - ) - ) - ); - - const result = await login({ username, password }) - .then((it) => it.playlist(id)); - - expect(result).toEqual({ - id, - name, - entries: [ - { ...track1, number: 1 }, - { ...track2, number: 2 }, - ], - }); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - }), - headers, - }); - }); - }); - - describe("and it has no tracks", () => { - it("should return the playlist with empty entries", async () => { - const playlist = aPlaylist({ - entries: [], - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListJson(playlist))) - ); - - const result = await login({ username, password }) - .then((it) => it.playlist(playlist.id)); - - expect(result).toEqual(playlist); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: playlist.id, - }), - headers, - }); - }); - }); - }); - }); - - describe("creating a playlist", () => { - it("should create a playlist with the given name", async () => { - const name = "ThePlaylist"; - const id = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(createPlayListJson({ id, name }))) - ); - - const result = await login({ username, password }) - .then((it) => it.createPlaylist(name)); - - expect(result).toEqual({ id, name }); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/createPlaylist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - f: "json", - name, - }), - headers, - }); - }); - }); - - describe("deleting a playlist", () => { - it("should delete the playlist by id", async () => { - const id = "id-to-delete"; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await login({ username, password }) - .then((it) => it.deletePlaylist(id)); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/deletePlaylist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - }), - headers, - }); - }); - }); - - describe("editing playlists", () => { - describe("adding a track to a playlist", () => { - it("should add it", async () => { - const playlistId = uuid(); - const trackId = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await login({ username, password }) - .then((it) => it.addToPlaylist(playlistId, trackId)); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - playlistId, - songIdToAdd: trackId, - }), - headers, - }); - }); - }); - - describe("removing a track from a playlist", () => { - it("should remove it", async () => { - const playlistId = uuid(); - const indicies = [6, 100, 33]; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await login({ username, password }) - .then((it) => it.removeFromPlaylist(playlistId, indicies)); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { - params: asURLSearchParams({ - ...authParamsPlusJson, - playlistId, - songIndexToRemove: indicies, - }), - headers, - }); - }); - }); - }); - }); - - describe("similarSongs", () => { - describe("when there is one similar songs", () => { - it("should return it", async () => { - const id = "idWithTracks"; - const pop = asGenre("Pop"); - - const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - const artist1 = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album1], - }); - - const track1 = aTrack({ - id: "track1", - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - genre: pop, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSimilarSongsJson([track1]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.similarSongs(id)); - - expect(result).toEqual([track1]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { - params: asURLSearchParams({ - ...authParams, - f: "json", - id, - count: 50, - }), - headers, - }); - }); - }); - - describe("when there are similar songs", () => { - it("should return them", async () => { - const id = "idWithTracks"; - const pop = asGenre("Pop"); - - const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - const artist1 = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album1], - }); - - const album2 = anAlbum({ id: "album2", name: "Walking", genre: pop }); - const artist2 = anArtist({ - id: "artist2", - name: "Bob Jane", - albums: [album2], - }); - - const track1 = aTrack({ - id: "track1", - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - genre: pop, - }); - const track2 = aTrack({ - id: "track2", - artist: artistToArtistSummary(artist2), - album: albumToAlbumSummary(album2), - genre: pop, - }); - const track3 = aTrack({ - id: "track3", - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - genre: pop, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSimilarSongsJson([track1, track2, track3]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist2, album2, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.similarSongs(id)); - - expect(result).toEqual([track1, track2, track3]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { - params: asURLSearchParams({ - ...authParams, - f: "json", - id, - count: 50, - }), - headers, - }); - }); - }); - - describe("when there are no similar songs", () => { - it("should return []", async () => { - const id = "idWithNoTracks"; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getSimilarSongsJson([]))) - ); - - const result = await login({ username, password }) - .then((it) => it.similarSongs(id)); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { - params: asURLSearchParams({ - ...authParams, - f: "json", - id, - count: 50, - }), - headers, - }); - }); - }); - - describe("when the id doesnt exist", () => { - it("should fail", async () => { - const id = "idThatHasAnError"; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(error("70", "data not found"))) - ); - - return expect( - login({ username, password }) - .then((it) => it.similarSongs(id)) - ).rejects.toEqual("Subsonic error:data not found"); - }); - }); - }); - - describe("topSongs", () => { - describe("when there is one top song", () => { - it("should return it", async () => { - const artistId = "bobMarleyId"; - const artistName = "Bob Marley"; - const pop = asGenre("Pop"); - - const album1 = anAlbum({ name: "Burnin", genre: pop }); - const artist = anArtist({ - id: artistId, - name: artistName, - albums: [album1], - }); - - const track1 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album1), - genre: pop, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getTopSongsJson([track1]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album1, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.topSongs(artistId)); - - expect(result).toEqual([track1]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, - }); - }); - }); - - describe("when there are many top songs", () => { - it("should return them", async () => { - const artistId = "bobMarleyId"; - const artistName = "Bob Marley"; - - const album1 = anAlbum({ name: "Burnin", genre: POP }); - const album2 = anAlbum({ name: "Churning", genre: POP }); - - const artist = anArtist({ - id: artistId, - name: artistName, - albums: [album1, album2], - }); - - const track1 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album1), - genre: POP, - }); - - const track2 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album2), - genre: POP, - }); - - const track3 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album1), - genre: POP, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getTopSongsJson([track1, track2, track3]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album1, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album2, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album1, []))) - ); - - const result = await login({ username, password }) - .then((it) => it.topSongs(artistId)); - - expect(result).toEqual([track1, track2, track3]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, - }); - }); - }); - - describe("when there are no similar songs", () => { - it("should return []", async () => { - const artistId = "bobMarleyId"; - const artistName = "Bob Marley"; - const pop = asGenre("Pop"); - - const album1 = anAlbum({ name: "Burnin", genre: pop }); - const artist = anArtist({ - id: artistId, - name: artistName, - albums: [album1], - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getTopSongsJson([]))) - ); - - - const result = await login({ username, password }) - .then((it) => it.topSongs(artistId)); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, - }); - }); - }); - }); }); diff --git a/tests/subsonic/generic.test.ts b/tests/subsonic/generic.test.ts new file mode 100644 index 0000000..500b509 --- /dev/null +++ b/tests/subsonic/generic.test.ts @@ -0,0 +1,4390 @@ +import { pipe } from "fp-ts/lib/function"; +import { option as O } from "fp-ts"; +import { v4 as uuid } from "uuid"; + +import axios from "axios"; +jest.mock("axios"); + + +import randomstring from "randomstring"; +jest.mock("randomstring"); + +import { aGenre, anAlbum, anArtist, aPlaylist, aPlaylistSummary, aSimilarArtist, aTrack, POP, ROCK } from "../builders"; +import { BUrn } from "../../src/burn"; +import { Album, AlbumQuery, AlbumSummary, albumToAlbumSummary, Artist, artistToArtistSummary, asArtistAlbumPairs, Playlist, PlaylistSummary, Rating, SimilarArtist, Track } from "../../src/music_service"; +import { artistImageURN, asGenre, asTrack, images, isValidImage, song, SubsonicGenericMusicLibrary } from "../../src/subsonic/generic"; +import { EMPTY, error, FAILURE, subsonicOK, ok } from "../subsonic.test"; +import Subsonic, { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; +import { asURLSearchParams } from "../../src/utils"; +import { b64Encode } from "../../src/b64"; + + +const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => pipe( + coverArt, + O.fromNullable, + O.map(it => it.resource.split(":")[1]), + O.getOrElseW(() => "") +) + +const asAlbumJson = ( + artist: { id: string | undefined; name: string | undefined }, + album: AlbumSummary, + tracks: Track[] = [] +) => ({ + id: album.id, + parent: artist.id, + isDir: "true", + title: album.name, + name: album.name, + album: album.name, + artist: artist.name, + genre: album.genre?.name, + coverArt: maybeIdFromCoverArtUrn(album.coverArt), + duration: "123", + playCount: "4", + year: album.year, + created: "2021-01-07T08:19:55.834207205Z", + artistId: artist.id, + songCount: "19", + isVideo: false, + song: tracks.map(asSongJson), +}); + +const asSongJson = (track: Track) => ({ + id: track.id, + parent: track.album.id, + title: track.name, + album: track.album.name, + artist: track.artist.name, + track: track.number, + genre: track.genre?.name, + isDir: "false", + coverArt: maybeIdFromCoverArtUrn(track.coverArt), + created: "2004-11-08T23:36:11", + duration: track.duration, + bitRate: 128, + size: "5624132", + suffix: "mp3", + contentType: track.mimeType, + isVideo: "false", + path: "ACDC/High voltage/ACDC - The Jack.mp3", + albumId: track.album.id, + artistId: track.artist.id, + type: "music", + starred: track.rating.love ? "sometime" : undefined, + userRating: track.rating.stars, + year: "", +}); + + +const asSimilarArtistJson = (similarArtist: SimilarArtist) => { + if (similarArtist.inLibrary) + return { + id: similarArtist.id, + name: similarArtist.name, + albumCount: 3, + }; + else + return { + id: -1, + name: similarArtist.name, + albumCount: 3, + }; +}; + +const getArtistInfoJson = ( + artist: Artist, + images: images = { + smallImageUrl: undefined, + mediumImageUrl: undefined, + largeImageUrl: undefined, + } +) => + subsonicOK({ + artistInfo2: { + ...images, + similarArtist: artist.similarArtists.map(asSimilarArtistJson), + }, + }); + +const getAlbumListJson = (albums: [Artist, Album][]) => + subsonicOK({ + albumList2: { + album: albums.map(([artist, album]) => asAlbumJson(artist, album)), + }, + }); + +type ArtistExtras = { artistImageUrl: string | undefined } + +const asArtistJson = ( + artist: Artist, + extras: ArtistExtras = { artistImageUrl: undefined } +) => ({ + id: artist.id, + name: artist.name, + albumCount: artist.albums.length, + album: artist.albums.map((it) => asAlbumJson(artist, it)), + ...extras, +}); + +const getArtistJson = (artist: Artist, extras: ArtistExtras = { artistImageUrl: undefined }) => + subsonicOK({ + artist: asArtistJson(artist, extras), + }); + +const asGenreJson = (genre: { name: string; albumCount: number }) => ({ + songCount: 1475, + albumCount: genre.albumCount, + value: genre.name, +}); + +const getGenresJson = (genres: { name: string; albumCount: number }[]) => + subsonicOK({ + genres: { + genre: genres.map(asGenreJson), + }, + }); + +const getAlbumJson = (artist: Artist, album: Album, tracks: Track[]) => + subsonicOK({ album: asAlbumJson(artist, album, tracks) }); + +const getSongJson = (track: Track) => subsonicOK({ song: asSongJson(track) }); + + +const getSimilarSongsJson = (tracks: Track[]) => + subsonicOK({ similarSongs2: { song: tracks.map(asSongJson) } }); + +const getTopSongsJson = (tracks: Track[]) => + subsonicOK({ topSongs: { song: tracks.map(asSongJson) } }); + +export type ArtistWithAlbum = { + artist: Artist; + album: Album; +}; + +const asPlaylistJson = (playlist: PlaylistSummary) => ({ + id: playlist.id, + name: playlist.name, + songCount: 1, + duration: 190, + public: true, + owner: "bob", + created: "2021-05-06T02:07:24.308007023Z", + changed: "2021-05-06T02:08:06Z", +}); + +const getPlayListsJson = (playlists: PlaylistSummary[]) => + subsonicOK({ + playlists: { + playlist: playlists.map(asPlaylistJson), + }, + }); + +const createPlayListJson = (playlist: PlaylistSummary) => + subsonicOK({ + playlist: asPlaylistJson(playlist), + }); + +const getPlayListJson = (playlist: Playlist) => + subsonicOK({ + playlist: { + id: playlist.id, + name: playlist.name, + songCount: playlist.entries.length, + duration: 627, + public: true, + owner: "bob", + created: "2021-05-06T02:07:30.460465988Z", + changed: "2021-05-06T02:40:04Z", + entry: playlist.entries.map((it) => ({ + id: it.id, + parent: "...", + isDir: false, + title: it.name, + album: it.album.name, + artist: it.artist.name, + track: it.number, + year: it.album.year, + genre: it.album.genre?.name, + coverArt: maybeIdFromCoverArtUrn(it.coverArt), + size: 123, + contentType: it.mimeType, + suffix: "mp3", + duration: it.duration, + bitRate: 128, + path: "...", + discNumber: 1, + created: "2019-09-04T04:07:00.138169924Z", + albumId: it.album.id, + artistId: it.artist.id, + type: "music", + isVideo: false, + starred: it.rating.love ? "sometime" : undefined, + userRating: it.rating.stars, + })), + }, + }); + +const getSearchResult3Json = ({ + artists, + albums, + tracks, +}: Partial<{ + artists: Artist[]; + albums: ArtistWithAlbum[]; + tracks: Track[]; +}>) => + subsonicOK({ + searchResult3: { + artist: (artists || []).map((it) => asArtistJson({ ...it, albums: [] })), + album: (albums || []).map((it) => asAlbumJson(it.artist, it.album, [])), + song: (tracks || []).map((it) => asSongJson(it)), + }, + }); + +const asArtistsJson = (artists: Artist[]) => { + const as: Artist[] = []; + const bs: Artist[] = []; + const cs: Artist[] = []; + const rest: Artist[] = []; + artists.forEach((it) => { + const firstChar = it.name.toLowerCase()[0]; + switch (firstChar) { + case "a": + as.push(it); + break; + case "b": + bs.push(it); + break; + case "c": + cs.push(it); + break; + default: + rest.push(it); + break; + } + }); + + const asArtistSummary = (artist: Artist) => ({ + id: artist.id, + name: artist.name, + albumCount: artist.albums.length, + }); + + return subsonicOK({ + artists: { + index: [ + { + name: "A", + artist: as.map(asArtistSummary), + }, + { + name: "B", + artist: bs.map(asArtistSummary), + }, + { + name: "C", + artist: cs.map(asArtistSummary), + }, + { + name: "D-Z", + artist: rest.map(asArtistSummary), + }, + ], + }, + }); +}; + +describe("isValidImage", () => { + describe("when ends with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { + it("is dodgy", () => { + expect( + isValidImage("http://something/2a96cbd8b46e442fc41c2b86b821562f.png") + ).toEqual(false); + }); + }); + describe("when does not end with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { + it("is dodgy", () => { + expect(isValidImage("http://something/somethingelse.png")).toEqual(true); + expect( + isValidImage( + "http://something/2a96cbd8b46e442fc41c2b86b821562f.png?withsomequerystring=true" + ) + ).toEqual(true); + }); + }); +}); + +describe("artistImageURN", () => { + describe("when artist URL is", () => { + describe("a valid external URL", () => { + it("should return an external URN", () => { + expect( + artistImageURN({ artistId: "someArtistId", artistImageURL: "http://example.com/image.jpg" }) + ).toEqual({ system: "external", resource: "http://example.com/image.jpg" }); + }); + }); + + describe("an invalid external URL", () => { + describe("and artistId is valid", () => { + it("should return an external URN", () => { + expect( + artistImageURN({ + artistId: "someArtistId", + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + }) + ).toEqual({ system: "subsonic", resource: "art:someArtistId" }); + }); + }); + + describe("and artistId is -1", () => { + it("should return an error icon urn", () => { + expect( + artistImageURN({ + artistId: "-1", + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + }) + ).toBeUndefined(); + }); + }); + + describe("and artistId is undefined", () => { + it("should return an error icon urn", () => { + expect( + artistImageURN({ + artistId: undefined, + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + }) + ).toBeUndefined(); + }); + }); + }); + + describe("undefined", () => { + describe("and artistId is valid", () => { + it("should return artist art by artist id URN", () => { + expect(artistImageURN({ artistId: "someArtistId", artistImageURL: undefined })).toEqual({system:"subsonic", resource:"art:someArtistId"}); + }); + }); + + describe("and artistId is -1", () => { + it("should return error icon", () => { + expect(artistImageURN({ artistId: "-1", artistImageURL: undefined })).toBeUndefined(); + }); + }); + + describe("and artistId is undefined", () => { + it("should return error icon", () => { + expect(artistImageURN({ artistId: undefined, artistImageURL: undefined })).toBeUndefined(); + }); + }); + }); + }); +}); + +describe("asTrack", () => { + describe("when the song has no artistId", () => { + const album = anAlbum(); + const track = aTrack({ artist: { id: undefined, name: "Not in library so no id", image: undefined }}); + + it("should provide no artistId", () => { + const result = asTrack(album, { ...asSongJson(track) }); + expect(result.artist.id).toBeUndefined(); + expect(result.artist.name).toEqual("Not in library so no id"); + expect(result.artist.image).toBeUndefined(); + }); + }); + + describe("when the song has no artist name", () => { + const album = anAlbum(); + + it("should provide a ? to sonos", () => { + const result = asTrack(album, { id: '1' } as any as song); + expect(result.artist.id).toBeUndefined(); + expect(result.artist.name).toEqual("?"); + expect(result.artist.image).toBeUndefined(); + }); + }); + + describe("invalid rating.stars values", () => { + const album = anAlbum(); + const track = aTrack(); + + describe("a value greater than 5", () => { + it("should be returned as 0", () => { + const result = asTrack(album, { ...asSongJson(track), userRating: 6 }); + expect(result.rating.stars).toEqual(0); + }); + }); + + describe("a value less than 0", () => { + it("should be returned as 0", () => { + const result = asTrack(album, { ...asSongJson(track), userRating: -1 }); + expect(result.rating.stars).toEqual(0); + }); + }); + }); +}); + +describe("SubsonicGenericMusicLibrary", () => { + const mockRandomstring = jest.fn(); + const mockGET = jest.fn(); + const mockPOST = jest.fn(); + + const url = "http://127.0.0.22:4567"; + const username = `user1-${uuid()}`; + const password = `pass1-${uuid()}`; + const salt = "saltysalty"; + + const streamClientApplication = jest.fn(); + const subsonic = new Subsonic(url, streamClientApplication) + const generic = new SubsonicGenericMusicLibrary( + subsonic, + { + username, + password, + type: 'subsonic', + bearer: undefined + } + ); + + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + + randomstring.generate = mockRandomstring; + axios.get = mockGET; + axios.post = mockPOST; + + mockRandomstring.mockReturnValue(salt); + }); + + const authParams = { + u: username, + v: "1.16.1", + c: "bonob", + t: t(password, salt), + s: salt, + }; + + const authParamsPlusJson = { + ...authParams, + f: "json", + }; + + const headers = { + "User-Agent": "bonob", + }; + + describe("getting genres", () => { + describe("when there are none", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(getGenresJson([])))); + }); + + it("should return empty array", async () => { + const result = await generic.genres(); + + expect(result).toEqual([]); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + + describe("when there is only 1 that has an albumCount > 0", () => { + const genres = [ + { name: "genre1", albumCount: 1 }, + { name: "genreWithNoAlbums", albumCount: 0 }, + ]; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson(genres))) + ); + }); + + it("should return them alphabetically sorted", async () => { + const result = await generic.genres(); + + expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + + describe("when there are many that have an albumCount > 0", () => { + const genres = [ + { name: "g1", albumCount: 1 }, + { name: "g2", albumCount: 1 }, + { name: "g3", albumCount: 1 }, + { name: "g4", albumCount: 1 }, + { name: "someGenreWithNoAlbums", albumCount: 0 }, + ]; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson(genres))) + ); + }); + + it("should return them alphabetically sorted", async () => { + const result = await generic.genres(); + + expect(result).toEqual([ + { id: b64Encode("g1"), name: "g1" }, + { id: b64Encode("g2"), name: "g2" }, + { id: b64Encode("g3"), name: "g3" }, + { id: b64Encode("g4"), name: "g4" }, + ]); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + }); + + describe("getting an artist", () => { + describe("when the artist exists", () => { + describe("and has many similar artists", () => { + const album1: Album = anAlbum({ genre: asGenre("Pop") }); + + const album2: Album = anAlbum({ genre: asGenre("Pop") }); + + const artist: Artist = anArtist({ + albums: [album1, album2], + similarArtists: [ + aSimilarArtist({ + id: "similar1.id", + name: "similar1", + inLibrary: true, + }), + aSimilarArtist({ id: "-1", name: "similar2", inLibrary: false }), + aSimilarArtist({ + id: "similar3.id", + name: "similar3", + inLibrary: true, + }), + aSimilarArtist({ id: "-1", name: "similar4", inLibrary: false }), + ], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return the similar artists", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: `${artist.id}`, + name: artist.name, + image: { system:"subsonic", resource:`art:${artist.id}` }, + albums: artist.albums, + similarArtists: artist.similarArtists, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has one similar artist", () => { + const album1: Album = anAlbum({ genre: asGenre("G1") }); + + const album2: Album = anAlbum({ genre: asGenre("G2") }); + + const artist: Artist = anArtist({ + albums: [album1, album2], + similarArtists: [ + aSimilarArtist({ + id: "similar1.id", + name: "similar1", + inLibrary: true, + }), + ], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return the similar artists", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system:"subsonic", resource:`art:${artist.id}` }, + albums: artist.albums, + similarArtists: artist.similarArtists, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has no similar artists", () => { + const album1: Album = anAlbum({ genre: asGenre("Jock") }); + + const album2: Album = anAlbum({ genre: asGenre("Mock") }); + + const artist: Artist = anArtist({ + albums: [album1, album2], + similarArtists: [], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return the similar artists", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system:"subsonic", resource: `art:${artist.id}` }, + albums: artist.albums, + similarArtists: artist.similarArtists, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has dodgy looking artist image uris", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl}))) + ); + }); + + it("should return remove the dodgy looking image uris and return urn for artist:id", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { + system: "subsonic", + resource: `art:${artist.id}`, + }, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has a good external image uri from getArtist route", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: 'http://example.com:1234/good/looking/image.png' }))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl }))) + ); + }); + + it("should use the external url", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system: "external", resource: 'http://example.com:1234/good/looking/image.png' }, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has a good large external image uri from getArtistInfo route", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: 'http://example.com:1234/good/large/image.png' }))) + ); + }); + + it("should use the external url", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system: "external", resource: 'http://example.com:1234/good/large/image.png' }, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + + describe("and has a good medium external image uri from getArtistInfo route", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: 'http://example.com:1234/good/medium/image.png', largeImageUrl: dodgyImageUrl }))) + ); + }); + + it("should use the external url", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: { system:"external", resource: 'http://example.com:1234/good/medium/image.png' }, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has multiple albums", () => { + const album1: Album = anAlbum({ genre: asGenre("Pop") }); + + const album2: Album = anAlbum({ genre: asGenre("Flop") }); + + const artist: Artist = anArtist({ + albums: [album1, album2], + similarArtists: [], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return it", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: artist.image, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has only 1 album", () => { + const album: Album = anAlbum({ genre: asGenre("Pop") }); + + const artist: Artist = anArtist({ + albums: [album], + similarArtists: [], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return it", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: artist.image, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + + describe("and has no albums", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return it", async () => { + const result: Artist = await generic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: artist.image, + albums: [], + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + }); + }); + }); + }); + }); + + describe("getting artists", () => { + describe("when subsonic flavour is generic", () => { + describe("when there are indexes, but no artists", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + subsonicOK({ + artists: { + index: [ + { + name: "#", + }, + { + name: "A", + }, + { + name: "B", + }, + ], + }, + }) + ) + ) + ); + }); + + it("should return empty", async () => { + const artists = await generic.artists({ _index: 0, _count: 100 }); + + expect(artists).toEqual({ + results: [], + total: 0, + }); + }); + }); + + describe("when there no indexes and no artists", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + subsonicOK({ + artists: {}, + }) + ) + ) + ); + }); + + it("should return empty", async () => { + const artists = await generic.artists({ _index: 0, _count: 100 }); + + expect(artists).toEqual({ + results: [], + total: 0, + }); + }); + }); + + describe("when there is one index and one artist", () => { + const artist1 = anArtist({albums:[anAlbum(), anAlbum(), anAlbum(), anAlbum()]}); + + const asArtistsJson = subsonicOK({ + artists: { + index: [ + { + name: "#", + artist: [ + { + id: artist1.id, + name: artist1.name, + albumCount: artist1.albums.length, + }, + ], + }, + ], + }, + }); + + describe("when it all fits on one page", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson))); + }); + + it("should return the single artist", async () => { + const artists = await generic.artists({ _index: 0, _count: 100 }); + + const expectedResults = [{ + id: artist1.id, + image: artist1.image, + name: artist1.name, + sortName: artist1.name + }]; + + expect(artists).toEqual({ + results: expectedResults, + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + }); + + describe("when there are artists", () => { + const artist1 = anArtist({ name: "A Artist", albums:[anAlbum()] }); + const artist2 = anArtist({ name: "B Artist" }); + const artist3 = anArtist({ name: "C Artist" }); + const artist4 = anArtist({ name: "D Artist" }); + const artists = [artist1, artist2, artist3, artist4]; + + describe("when no paging is in effect", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ); + }); + + it("should return all the artists", async () => { + const artists = await generic.artists({ _index: 0, _count: 100 }); + + const expectedResults = [artist1, artist2, artist3, artist4].map( + (it) => ({ + id: it.id, + image: it.image, + name: it.name, + sortName: it.name, + }) + ); + + expect(artists).toEqual({ + results: expectedResults, + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + + describe("when paging specified", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ); + }); + + it("should return only the correct page of artists", async () => { + const artists = await generic.artists({ _index: 1, _count: 2 }); + + const expectedResults = [artist2, artist3].map((it) => ({ + id: it.id, + image: it.image, + name: it.name, + sortName: it.name, + })); + + expect(artists).toEqual({ results: expectedResults, total: 4 }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + }); + }); + + // todo: put this test back + // describe("when the subsonic type is navidrome", () => { + // const ndArtist1 = { + // id: uuid(), + // name: "Artist1", + // orderArtistName: "Artist1", + // largeImageUrl: "http://example.com/artist1/image.jpg" + // }; + // const ndArtist2 = { + // id: uuid(), + // name: "Artist2", + // orderArtistName: "The Artist2", + // largeImageUrl: undefined + // }; + // const ndArtist3 = { + // id: uuid(), + // name: "Artist3", + // orderArtistName: "An Artist3", + // largeImageUrl: `http://example.com/artist3/${DODGY_IMAGE_NAME}` + // }; + // const ndArtist4 = { + // id: uuid(), + // name: "Artist4", + // orderArtistName: "An Artist4", + // largeImageUrl: `http://example.com/artist4/${DODGY_IMAGE_NAME}` + // }; + // const bearer = `bearer-${uuid()}`; + + // describe("when no paging is specified", () => { + // beforeEach(() => { + // (axios.get as jest.Mock) + // .mockImplementationOnce(() => Promise.resolve(ok(pingJson({ type: "navidrome" })))) + // .mockImplementationOnce(() => + // Promise.resolve({ + // status: 200, + // data: [ + // ndArtist1, + // ndArtist2, + // ndArtist3, + // ndArtist4, + // ], + // headers: { + // "x-total-count": "4" + // } + // }) + // ); + + // (axios.post as jest.Mock).mockResolvedValue(ok({ token: bearer })); + // }); + + // it("should fetch all artists", async () => { + // const artists = await login({ username, password, bearer, type: "navidrome" }) + // .then((it) => it.artists({ _index: undefined, _count: undefined })); + + // expect(artists).toEqual({ + // results: [ + // artistSummaryFromNDArtist(ndArtist1), + // artistSummaryFromNDArtist(ndArtist2), + // artistSummaryFromNDArtist(ndArtist3), + // artistSummaryFromNDArtist(ndArtist4), + // ], + // total: 4, + // }); + + // expect(axios.get).toHaveBeenCalledWith(`${url}/api/artist`, { + // params: asURLSearchParams({ + // _sort: "name", + // _order: "ASC", + // _start: "0" + // }), + // headers: { + // "User-Agent": "bonob", + // "x-nd-authorization": `Bearer ${bearer}`, + // }, + // }); + // }); + // }); + + // describe("when start index is specified", () => { + // beforeEach(() => { + // (axios.get as jest.Mock) + // .mockImplementationOnce(() => Promise.resolve(ok(pingJson({ type: "navidrome" })))) + // .mockImplementationOnce(() => + // Promise.resolve({ + // status: 200, + // data: [ + // ndArtist3, + // ndArtist4, + // ], + // headers: { + // "x-total-count": "5" + // } + // }) + // ); + + // (axios.post as jest.Mock).mockResolvedValue(ok({ token: bearer })); + // }); + + // it("should fetch all artists", async () => { + // const artists = await login({ username, password, bearer, type: "navidrome" }) + // .then((it) => it.artists({ _index: 2, _count: undefined })); + + // expect(artists).toEqual({ + // results: [ + // artistSummaryFromNDArtist(ndArtist3), + // artistSummaryFromNDArtist(ndArtist4), + // ], + // total: 5, + // }); + + // expect(axios.get).toHaveBeenCalledWith(`${url}/api/artist`, { + // params: asURLSearchParams({ + // _sort: "name", + // _order: "ASC", + // _start: "2" + // }), + // headers: { + // "User-Agent": "bonob", + // "x-nd-authorization": `Bearer ${bearer}`, + // }, + // }); + // }); + // }); + + // describe("when start index and count is specified", () => { + // beforeEach(() => { + // (axios.get as jest.Mock) + // .mockImplementationOnce(() => Promise.resolve(ok(pingJson({ type: "navidrome" })))) + // .mockImplementationOnce(() => + // Promise.resolve({ + // status: 200, + // data: [ + // ndArtist3, + // ndArtist4, + // ], + // headers: { + // "x-total-count": "5" + // } + // }) + // ); + + // (axios.post as jest.Mock).mockResolvedValue(ok({ token: bearer })); + // }); + + // it("should fetch all artists", async () => { + // const artists = await login({ username, password, bearer, type: "navidrome" }) + // .then((it) => it.artists({ _index: 2, _count: 23 })); + + // expect(artists).toEqual({ + // results: [ + // artistSummaryFromNDArtist(ndArtist3), + // artistSummaryFromNDArtist(ndArtist4), + // ], + // total: 5, + // }); + + // expect(axios.get).toHaveBeenCalledWith(`${url}/api/artist`, { + // params: asURLSearchParams({ + // _sort: "name", + // _order: "ASC", + // _start: "2", + // _end: "25" + // }), + // headers: { + // "User-Agent": "bonob", + // "x-nd-authorization": `Bearer ${bearer}`, + // }, + // }); + // }); + // }); + + // }); + }); + + describe("getting albums", () => { + describe("filtering", () => { + const album1 = anAlbum({ id: "album1", genre: asGenre("Pop") }); + const album2 = anAlbum({ id: "album2", genre: asGenre("Rock") }); + const album3 = anAlbum({ id: "album3", genre: asGenre("Pop") }); + const album4 = anAlbum({ id: "album4", genre: asGenre("Pop") }); + const album5 = anAlbum({ id: "album5", genre: asGenre("Pop") }); + + const artist = anArtist({ + albums: [album1, album2, album3, album4, album5], + }); + + describe("by genre", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist, album1], + // album2 is not Pop + [artist, album3], + ]) + ) + ) + ); + }); + + it("should map the 64 encoded genre back into the subsonic genre", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + genre: b64Encode("Pop"), + type: "byGenre", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album1, album3].map(albumToAlbumSummary), + total: 2, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "byGenre", + genre: "Pop", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("by newest", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist, album3], + [artist, album2], + [artist, album1], + ]) + ) + ) + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "recentlyAdded", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album3, album2, album1].map(albumToAlbumSummary), + total: 3, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "newest", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("by recently played", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist, album3], + [artist, album2], + // album1 never played + ]) + ) + ) + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "recentlyPlayed", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album3, album2].map(albumToAlbumSummary), + total: 2, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "recent", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("by frequently played", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce( + () => + // album1 never played + Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) + // album3 never played + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { _index: 0, _count: 100, type: "mostPlayed" }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album2].map(albumToAlbumSummary), + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "frequent", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("by starred", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce( + () => + // album1 never played + Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) + // album3 never played + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { _index: 0, _count: 100, type: "starred" }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album2].map(albumToAlbumSummary), + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "highest", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + }); + + describe("when the artist has only 1 album", () => { + const artist = anArtist({ + name: "one hit wonder", + albums: [anAlbum({ genre: asGenre("Pop") })], + }); + const artists = [artist]; + const albums = artists.flatMap((artist) => artist.albums); + + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) + ); + }); + + it("should return the album", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: albums, + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("when the only artist has no albums", () => { + const artist = anArtist({ + name: "no hit wonder", + albums: [], + }); + const artists = [artist]; + const albums = artists.flatMap((artist) => artist.albums); + + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) + ); + }); + + it("should return the album", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: albums, + total: 0, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("when there are 6 albums in total", () => { + const genre1 = asGenre("genre1"); + const genre2 = asGenre("genre2"); + const genre3 = asGenre("genre3"); + + const artist1 = anArtist({ + name: "abba", + albums: [ + anAlbum({ name: "album1", genre: genre1 }), + anAlbum({ name: "album2", genre: genre2 }), + anAlbum({ name: "album3", genre: genre3 }), + ], + }); + const artist2 = anArtist({ + name: "babba", + albums: [ + anAlbum({ name: "album4", genre: genre1 }), + anAlbum({ name: "album5", genre: genre2 }), + anAlbum({ name: "album6", genre: genre3 }), + ], + }); + const artists = [artist1, artist2]; + const albums = artists.flatMap((artist) => artist.albums); + + describe("querying for all of them", () => { + it("should return all of them with corrent paging information", async () => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) + ); + + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: albums, + total: 6, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); + + describe("querying for a page of them", () => { + it("should return the page with the corrent paging information", async () => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, artist1.albums[2]!], + [artist2, artist2.albums[0]!], + // due to pre-fetch will get next 2 albums also + [artist2, artist2.albums[1]!], + [artist2, artist2.albums[2]!], + ]) + ) + ) + ); + + const q: AlbumQuery = { + _index: 2, + _count: 2, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [artist1.albums[2], artist2.albums[0]], + total: 6, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: 2, + }), + headers, + }); + }); + }); + }); + + describe("when the number of albums reported by getArtists does not match that of getAlbums", () => { + const genre = asGenre("lofi"); + + const album1 = anAlbum({ name: "album1", genre }); + const album2 = anAlbum({ name: "album2", genre }); + const album3 = anAlbum({ name: "album3", genre }); + const album4 = anAlbum({ name: "album4", genre }); + const album5 = anAlbum({ name: "album5", genre }); + + // the artists have 5 albums in the getArtists endpoint + const artist1 = anArtist({ + albums: [album1, album2, album3, album4], + }); + const artist2 = anArtist({ + albums: [album5], + }); + const artists = [artist1, artist2]; + + describe("when the number of albums returned from getAlbums is less the number of albums in the getArtists endpoint", () => { + describe("when the query comes back on 1 page", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + [artist1, album3], + // album4 is missing from the albums end point for some reason + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should return the page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album1, album2, album3, album5], + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + + describe("when the query is for the first page", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + // album3 & album5 is returned due to the prefetch + [artist1, album3], + // album4 is missing from the albums end point for some reason + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should filter out the pre-fetched albums", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 2, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album1, album2], + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + + describe("when the query is for the last page only", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + // album1 is on the first page + // album2 is on the first page + [artist1, album3], + // album4 is missing from the albums end point for some reason + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should return the last page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 2, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album3, album5], + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + }); + + describe("when the number of albums returned from getAlbums is more than the number of albums in the getArtists endpoint", () => { + describe("when the query comes back on 1 page", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + asArtistsJson([ + // artist1 has lost 2 albums on the getArtists end point + { ...artist1, albums: [album1, album2] }, + artist2, + ]) + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + [artist1, album3], + [artist1, album4], + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should return the page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album1, album2, album3, album4, album5], + total: 5, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + + describe("when the query is for the first page", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + asArtistsJson([ + // artist1 has lost 2 albums on the getArtists end point + { ...artist1, albums: [album1, album2] }, + artist2, + ]) + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + [artist1, album3], + [artist1, album4], + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should filter out the pre-fetched albums", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 2, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album1, album2], + total: 5, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + + describe("when the query is for the last page only", () => { + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + asArtistsJson([ + // artist1 has lost 2 albums on the getArtists end point + { ...artist1, albums: [album1, album2] }, + artist2, + ]) + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album3], + [artist1, album4], + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should return the last page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 2, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await generic.albums(q); + + expect(result).toEqual({ + results: [album3, album4, album5], + total: 5, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + }); + }); + }); + + describe("getting an album", () => { + describe("when it exists", () => { + const genre = asGenre("Pop"); + + const album = anAlbum({ genre }); + + const artist = anArtist({ albums: [album] }); + + const tracks = [ + aTrack({ artist, album, genre }), + aTrack({ artist, album, genre }), + aTrack({ artist, album, genre }), + aTrack({ artist, album, genre }), + ]; + + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); + }); + + it("should return the album", async () => { + const result = await generic.album(album.id); + + expect(result).toEqual(album); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + }); + + describe("getting tracks", () => { + describe("for an album", () => { + describe("when the album has multiple tracks, some of which are rated", () => { + const hipHop = asGenre("Hip-Hop"); + const tripHop = asGenre("Trip-Hop"); + + const album = anAlbum({ id: "album1", name: "Burnin", genre: hipHop }); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + + const track1 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: hipHop, + rating: { + love: true, + stars: 3, + }, + }); + const track2 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: hipHop, + rating: { + love: false, + stars: 0, + }, + }); + const track3 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: tripHop, + rating: { + love: true, + stars: 5, + }, + }); + const track4 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: tripHop, + rating: { + love: false, + stars: 1, + }, + }); + + const tracks = [track1, track2, track3, track4]; + + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); + }); + + it("should return the album", async () => { + const result = await generic.tracks(album.id); + + expect(result).toEqual([track1, track2, track3, track4]); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + + describe("when the album has only 1 track", () => { + const flipFlop = asGenre("Flip-Flop"); + + const album = anAlbum({ + id: "album1", + name: "Burnin", + genre: flipFlop, + }); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: flipFlop, + }); + + const tracks = [track]; + + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); + }); + + it("should return the album", async () => { + const result = await generic.tracks(album.id); + + expect(result).toEqual([track]); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + + describe("when the album has only no tracks", () => { + const album = anAlbum({ id: "album1", name: "Burnin" }); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + + const tracks: Track[] = []; + + beforeEach(() => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); + }); + + it("should empty array", async () => { + const result = await generic.tracks(album.id); + + expect(result).toEqual([]); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + }); + + describe("a single track", () => { + const pop = asGenre("Pop"); + + const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + + describe("that is starred", () => { + it("should return the track", async () => { + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + rating: { + love: true, + stars: 4, + }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await generic.track(track.id); + + expect(result).toEqual({ + ...track, + rating: { love: true, stars: 4 }, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: track.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + + describe("that is not starred", () => { + it("should return the track", async () => { + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + rating: { + love: false, + stars: 0, + }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await generic.track(track.id); + + expect(result).toEqual({ + ...track, + rating: { love: false, stars: 0 }, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: track.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); + }); + }); + }); + }); + + describe("streaming a track", () => { + const trackId = uuid(); + const genre = aGenre("foo"); + + const album = anAlbum({ genre }); + const artist = anArtist({ + albums: [album] + }); + const track = aTrack({ + id: trackId, + album: albumToAlbumSummary(album), + artist: artistToArtistSummary(artist), + genre, + }); + + describe("content-range, accept-ranges or content-length", () => { + beforeEach(() => { + streamClientApplication.mockReturnValue("bonob"); + }); + + describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => { + it("should return undefined values", async () => { + const stream = { + pipe: jest.fn(), + }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + }, + data: stream, + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.stream({ trackId, range: undefined }); + + expect(result.headers).toEqual({ + "content-type": "audio/mpeg", + "content-length": undefined, + "content-range": undefined, + "accept-ranges": undefined, + }); + }); + }); + + describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => { + it("should return undefined values", async () => { + const stream = { + pipe: jest.fn(), + }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + "content-length": undefined, + "content-range": undefined, + "accept-ranges": undefined, + }, + data: stream, + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.stream({ trackId, range: undefined }); + + expect(result.headers).toEqual({ + "content-type": "audio/mpeg", + "content-length": undefined, + "content-range": undefined, + "accept-ranges": undefined, + }); + }); + }); + + describe("with no range specified", () => { + describe("navidrome returns a 200", () => { + it("should return the content", async () => { + const stream = { + pipe: jest.fn(), + }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + "content-length": "1667", + "content-range": "-200", + "accept-ranges": "bytes", + "some-other-header": "some-value", + }, + data: stream, + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.stream({ trackId, range: undefined }); + + expect(result.headers).toEqual({ + "content-type": "audio/mpeg", + "content-length": "1667", + "content-range": "-200", + "accept-ranges": "bytes", + }); + expect(result.stream).toEqual(stream); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { + params: asURLSearchParams({ + ...authParams, + id: trackId, + }), + headers: { + "User-Agent": "bonob", + }, + responseType: "stream", + }); + }); + }); + + describe("navidrome returns something other than a 200", () => { + it("should fail", async () => { + const trackId = "track123"; + + const streamResponse = { + status: 400, + headers: { + 'content-type': 'text/html', + 'content-length': '33' + } + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + return expect( + generic.stream({ trackId, range: undefined }) + ).rejects.toEqual(`Subsonic failed with a 400 status`); + }); + }); + + describe("io exception occurs", () => { + it("should fail", async () => { + const trackId = "track123"; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.reject("IO error occured")); + + return expect( + generic.stream({ trackId, range: undefined }) + ).rejects.toEqual(`Subsonic failed with: IO error occured`); + }); + }); + }); + + describe("with range specified", () => { + it("should send the range to navidrome", async () => { + const stream = { + pipe: jest.fn(), + }; + + const range = "1000-2000"; + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/flac", + "content-length": "66", + "content-range": "100-200", + "accept-ranges": "none", + "some-other-header": "some-value", + }, + data: stream, + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.stream({ trackId, range }); + + expect(result.headers).toEqual({ + "content-type": "audio/flac", + "content-length": "66", + "content-range": "100-200", + "accept-ranges": "none", + }); + expect(result.stream).toEqual(stream); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { + params: asURLSearchParams({ + ...authParams, + id: trackId, + }), + headers: { + "User-Agent": "bonob", + Range: range, + }, + responseType: "stream", + }); + }); + }); + }); + + describe("when navidrome has a custom StreamClientApplication registered", () => { + describe("when no range specified", () => { + it("should user the custom StreamUserAgent when calling navidrome", async () => { + const clientApplication = `bonob-${uuid()}`; + streamClientApplication.mockReturnValue(clientApplication); + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + }, + data: Buffer.from("the track", "ascii"), + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, [track]))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + await generic.stream({ trackId, range: undefined }); + + expect(streamClientApplication).toHaveBeenCalledWith(track); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { + params: asURLSearchParams({ + ...authParams, + id: trackId, + c: clientApplication, + }), + headers: { + "User-Agent": "bonob", + }, + responseType: "stream", + }); + }); + }); + + describe("when range specified", () => { + it("should user the custom StreamUserAgent when calling navidrome", async () => { + const range = "1000-2000"; + const clientApplication = `bonob-${uuid()}`; + streamClientApplication.mockReturnValue(clientApplication); + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + }, + data: Buffer.from("the track", "ascii"), + }; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, [track]))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + await generic.stream({ trackId, range }); + + expect(streamClientApplication).toHaveBeenCalledWith(track); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { + params: asURLSearchParams({ + ...authParams, + id: trackId, + c: clientApplication, + }), + headers: { + "User-Agent": "bonob", + Range: range, + }, + responseType: "stream", + }); + }); + }); + }); + }); + + describe("fetching cover art", () => { + describe("fetching album art", () => { + describe("when no size is specified", () => { + it("should fetch the image", async () => { + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; + const coverArtId = "someCoverArt"; + const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; + + mockGET + + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.coverArt(coverArtURN); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + }), + headers, + responseType: "arraybuffer", + }); + }); + }); + + describe("when size is specified", () => { + it("should fetch the image", async () => { + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; + const coverArtId = uuid(); + const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` } + const size = 1879; + + mockGET + + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.coverArt(coverArtURN, size); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + size, + }), + headers, + responseType: "arraybuffer", + }); + }); + }); + + describe("when an unexpected error occurs", () => { + it("should return undefined", async () => { + const size = 1879; + + mockGET + + .mockImplementationOnce(() => Promise.reject("BOOOM")); + + const result = await generic.coverArt({ system: "external", resource: "http://localhost:404" }, size); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe("fetching cover art", () => { + describe("when urn.resource is not subsonic", () => { + it("should be undefined", async () => { + const covertArtURN = { system: "notSubsonic", resource: `art:${uuid()}` }; + + mockGET + ; + + const result = await generic.coverArt(covertArtURN, 190); + + expect(result).toBeUndefined(); + }); + }); + + describe("when no size is specified", () => { + it("should fetch the image", async () => { + const coverArtId = uuid() + const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; + + mockGET + + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.coverArt(covertArtURN); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getCoverArt`, + { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + }), + headers, + responseType: "arraybuffer", + } + ); + }); + + describe("and an error occurs fetching the uri", () => { + it("should return undefined", async () => { + const coverArtId = uuid() + const covertArtURN = { system:"subsonic", resource: `art:${coverArtId}` }; + + mockGET + + .mockImplementationOnce(() => Promise.reject("BOOOM")); + + const result = await generic.coverArt(covertArtURN); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe("when size is specified", () => { + const size = 189; + + it("should fetch the image", async () => { + const coverArtId = uuid() + const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; + + mockGET + + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await generic.coverArt(covertArtURN, size); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getCoverArt`, + { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + size + }), + headers, + responseType: "arraybuffer", + } + ); + }); + + describe("and an error occurs fetching the uri", () => { + it("should return undefined", async () => { + const coverArtId = uuid() + const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; + + mockGET + + .mockImplementationOnce(() => Promise.reject("BOOOM")); + + const result = await generic.coverArt(covertArtURN, size); + + expect(result).toBeUndefined(); + }); + }); + }); + }); + }); + + describe("rate", () => { + const trackId = uuid(); + + const rate = (trackId: string, rating: Rating) => generic.rate(trackId, rating); + + const artist = anArtist(); + const album = anAlbum({ id: "album1", name: "Burnin", genre: POP }); + + describe("rating a track", () => { + describe("loving a track that isnt already loved", () => { + it("should mark the track as loved", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: false, stars: 0 }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/star`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + }); + }); + }); + + describe("unloving a track that is loved", () => { + it("should mark the track as loved", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 0 }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: false, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/unstar`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + }); + }); + }); + + describe("loving a track that is already loved", () => { + it("shouldn't do anything", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 0 }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledTimes(2); + }); + }); + + describe("rating a track with a different rating", () => { + it("should add the new rating", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: false, stars: 0 }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: false, stars: 3 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/setRating`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + rating: 3, + }), + headers, + }); + }); + }); + + describe("rating a track with the same rating it already has", () => { + it("shouldn't do anything", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 3 }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await rate(trackId, { love: true, stars: 3 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledTimes(2); + }); + }); + + describe("loving and rating a track", () => { + it("should return true", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 3 }, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: false, stars: 5 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/unstar`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + }); + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/setRating`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + rating: 5, + }), + headers, + }); + }); + }); + + describe("invalid star values", () => { + describe("stars of -1", () => { + it("should return false", async () => { + mockGET; + + const result = await rate(trackId, { love: true, stars: -1 }); + expect(result).toEqual(false); + }); + }); + + describe("stars of 6", () => { + it("should return false", async () => { + mockGET; + + const result = await rate(trackId, { love: true, stars: -1 }); + expect(result).toEqual(false); + }); + }); + }); + + describe("when fails", () => { + it("should return false", async () => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(FAILURE))) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(false); + }); + }); + }); + }); + + describe("scrobble", () => { + describe("when succeeds", () => { + it("should return true", async () => { + const id = uuid(); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.scrobble(id); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + submission: true, + }), + headers, + }); + }); + }); + + describe("when fails", () => { + it("should return false", async () => { + const id = uuid(); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve({ + status: 500, + data: {}, + }) + ); + + const result = await generic.scrobble(id); + + expect(result).toEqual(false); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + submission: true, + }), + headers, + }); + }); + }); + }); + + describe("nowPlaying", () => { + describe("when succeeds", () => { + it("should return true", async () => { + const id = uuid(); + + mockGET + + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.nowPlaying(id); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + submission: false, + }), + headers, + }); + }); + }); + + describe("when fails", () => { + it("should return false", async () => { + const id = uuid(); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve({ + status: 500, + data: {}, + }) + ); + + const result = await generic.nowPlaying(id); + + expect(result).toEqual(false); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + submission: false, + }), + headers, + }); + }); + }); + }); + + describe("searchArtists", () => { + describe("when there is 1 search results", () => { + it("should return true", async () => { + const artist1 = anArtist({ name: "foo woo" }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ artists: [artist1] }))) + ); + + const result = await generic.searchArtists("foo"); + + expect(result).toEqual([artistToArtistSummary(artist1)]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 20, + albumCount: 0, + songCount: 0, + query: "foo", + }), + headers, + }); + }); + }); + + describe("when there are many search results", () => { + it("should return true", async () => { + const artist1 = anArtist({ name: "foo woo" }); + const artist2 = anArtist({ name: "foo choo" }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok(getSearchResult3Json({ artists: [artist1, artist2] })) + ) + ); + + const result = await generic.searchArtists("foo"); + + expect(result).toEqual([ + artistToArtistSummary(artist1), + artistToArtistSummary(artist2), + ]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 20, + albumCount: 0, + songCount: 0, + query: "foo", + }), + headers, + }); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) + ); + + const result = await generic.searchArtists("foo"); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 20, + albumCount: 0, + songCount: 0, + query: "foo", + }), + headers, + }); + }); + }); + }); + + describe("searchAlbums", () => { + describe("when there is 1 search results", () => { + it("should return true", async () => { + const album = anAlbum({ + name: "foo woo", + genre: { id: b64Encode("pop"), name: "pop" }, + }); + const artist = anArtist({ name: "#1", albums: [album] }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok(getSearchResult3Json({ albums: [{ artist, album }] })) + ) + ); + + const result = await generic.searchAlbums("foo"); + + expect(result).toEqual([albumToAlbumSummary(album)]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "foo", + }), + headers, + }); + }); + }); + + describe("when there are many search results", () => { + it("should return true", async () => { + const album1 = anAlbum({ + name: "album1", + genre: { id: b64Encode("pop"), name: "pop" }, + }); + const artist1 = anArtist({ name: "artist1", albums: [album1] }); + + const album2 = anAlbum({ + name: "album2", + genre: { id: b64Encode("pop"), name: "pop" }, + }); + const artist2 = anArtist({ name: "artist2", albums: [album2] }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + getSearchResult3Json({ + albums: [ + { artist: artist1, album: album1 }, + { artist: artist2, album: album2 }, + ], + }) + ) + ) + ); + + const result = await generic.searchAlbums("moo"); + + expect(result).toEqual([ + albumToAlbumSummary(album1), + albumToAlbumSummary(album2), + ]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "moo", + }), + headers, + }); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ albums: [] }))) + ); + + const result = await generic.searchAlbums("foo"); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "foo", + }), + headers, + }); + }); + }); + }); + + describe("searchSongs", () => { + describe("when there is 1 search results", () => { + it("should return true", async () => { + const pop = asGenre("Pop"); + + const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ tracks: [track] }))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track)))) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await generic.searchTracks("foo"); + + expect(result).toEqual([track]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 0, + songCount: 20, + query: "foo", + }), + headers, + }); + }); + }); + + describe("when there are many search results", () => { + it("should return true", async () => { + const pop = asGenre("Pop"); + + const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist1 = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album1], + }); + const track1 = aTrack({ + id: "track1", + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + genre: pop, + }); + + const album2 = anAlbum({ id: "album2", name: "Bobbin", genre: pop }); + const artist2 = anArtist({ + id: "artist2", + name: "Jane Marley", + albums: [album2], + }); + const track2 = aTrack({ + id: "track2", + artist: artistToArtistSummary(artist2), + album: albumToAlbumSummary(album2), + genre: pop, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + getSearchResult3Json({ + tracks: [track1, track2], + }) + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track1))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track2))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist2, album2, []))) + ); + + const result = await generic.searchTracks("moo"); + + expect(result).toEqual([track1, track2]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 0, + songCount: 20, + query: "moo", + }), + headers, + }); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ tracks: [] }))) + ); + + const result = await generic.searchTracks("foo"); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 0, + songCount: 20, + query: "foo", + }), + headers, + }); + }); + }); + }); + + describe("playlists", () => { + describe("getting playlists", () => { + describe("when there is 1 playlist results", () => { + it("should return it", async () => { + const playlist = aPlaylistSummary(); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson([playlist]))) + ); + + const result = await generic.playlists(); + + expect(result).toEqual([playlist]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + + describe("when there are many playlists", () => { + it("should return them", async () => { + const playlist1 = aPlaylistSummary(); + const playlist2 = aPlaylistSummary(); + const playlist3 = aPlaylistSummary(); + const playlists = [playlist1, playlist2, playlist3]; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson(playlists))) + ); + + const result = await generic.playlists(); + + expect(result).toEqual(playlists); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + + describe("when there are no playlists", () => { + it("should return []", async () => { + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson([]))) + ); + + const result = await generic.playlists(); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + }); + + describe("getting a single playlist", () => { + describe("when there is no playlist with the id", () => { + it("should raise error", async () => { + const id = "id404"; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(error("70", "data not found"))) + ); + + return expect( + generic.playlist(id) + ).rejects.toEqual("Subsonic error:data not found"); + }); + }); + + describe("when there is a playlist with the id", () => { + describe("and it has tracks", () => { + it("should return the playlist with entries", async () => { + const id = uuid(); + const name = "Great Playlist"; + const artist1 = anArtist(); + const album1 = anAlbum({ + artistId: artist1.id, + artistName: artist1.name, + genre: POP, + }); + const track1 = aTrack({ + genre: POP, + number: 66, + coverArt: album1.coverArt, + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + }); + + const artist2 = anArtist(); + const album2 = anAlbum({ + artistId: artist2.id, + artistName: artist2.name, + genre: ROCK, + }); + const track2 = aTrack({ + genre: ROCK, + number: 77, + coverArt: album2.coverArt, + artist: artistToArtistSummary(artist2), + album: albumToAlbumSummary(album2), + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve( + ok( + getPlayListJson({ + id, + name, + entries: [track1, track2], + }) + ) + ) + ); + + const result = await generic.playlist(id); + + expect(result).toEqual({ + id, + name, + entries: [ + { ...track1, number: 1 }, + { ...track2, number: 2 }, + ], + }); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + }), + headers, + }); + }); + }); + + describe("and it has no tracks", () => { + it("should return the playlist with empty entries", async () => { + const playlist = aPlaylist({ + entries: [], + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getPlayListJson(playlist))) + ); + + const result = await generic.playlist(playlist.id); + + expect(result).toEqual(playlist); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: playlist.id, + }), + headers, + }); + }); + }); + }); + }); + + describe("creating a playlist", () => { + it("should create a playlist with the given name", async () => { + const name = "ThePlaylist"; + const id = uuid(); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(createPlayListJson({ id, name }))) + ); + + const result = await generic.createPlaylist(name); + + expect(result).toEqual({ id, name }); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/createPlaylist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + f: "json", + name, + }), + headers, + }); + }); + }); + + describe("deleting a playlist", () => { + it("should delete the playlist by id", async () => { + const id = "id-to-delete"; + + mockGET + + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.deletePlaylist(id); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/deletePlaylist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + }), + headers, + }); + }); + }); + + describe("editing playlists", () => { + describe("adding a track to a playlist", () => { + it("should add it", async () => { + const playlistId = uuid(); + const trackId = uuid(); + + mockGET + + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.addToPlaylist(playlistId, trackId); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + playlistId, + songIdToAdd: trackId, + }), + headers, + }); + }); + }); + + describe("removing a track from a playlist", () => { + it("should remove it", async () => { + const playlistId = uuid(); + const indicies = [6, 100, 33]; + + mockGET + + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await generic.removeFromPlaylist(playlistId, indicies); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + playlistId, + songIndexToRemove: indicies, + }), + headers, + }); + }); + }); + }); + }); + + describe("similarSongs", () => { + describe("when there is one similar songs", () => { + it("should return it", async () => { + const id = "idWithTracks"; + const pop = asGenre("Pop"); + + const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist1 = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album1], + }); + + const track1 = aTrack({ + id: "track1", + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + genre: pop, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([track1]))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) + ); + + const result = await generic.similarSongs(id); + + expect(result).toEqual([track1]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { + params: asURLSearchParams({ + ...authParams, + f: "json", + id, + count: 50, + }), + headers, + }); + }); + }); + + describe("when there are similar songs", () => { + it("should return them", async () => { + const id = "idWithTracks"; + const pop = asGenre("Pop"); + + const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist1 = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album1], + }); + + const album2 = anAlbum({ id: "album2", name: "Walking", genre: pop }); + const artist2 = anArtist({ + id: "artist2", + name: "Bob Jane", + albums: [album2], + }); + + const track1 = aTrack({ + id: "track1", + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + genre: pop, + }); + const track2 = aTrack({ + id: "track2", + artist: artistToArtistSummary(artist2), + album: albumToAlbumSummary(album2), + genre: pop, + }); + const track3 = aTrack({ + id: "track3", + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + genre: pop, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([track1, track2, track3]))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist2, album2, []))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) + ); + + const result = await generic.similarSongs(id); + + expect(result).toEqual([track1, track2, track3]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { + params: asURLSearchParams({ + ...authParams, + f: "json", + id, + count: 50, + }), + headers, + }); + }); + }); + + describe("when there are no similar songs", () => { + it("should return []", async () => { + const id = "idWithNoTracks"; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([]))) + ); + + const result = await generic.similarSongs(id); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { + params: asURLSearchParams({ + ...authParams, + f: "json", + id, + count: 50, + }), + headers, + }); + }); + }); + + describe("when the id doesnt exist", () => { + it("should fail", async () => { + const id = "idThatHasAnError"; + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(error("70", "data not found"))) + ); + + return expect( + generic.similarSongs(id) + ).rejects.toEqual("Subsonic error:data not found"); + }); + }); + }); + + describe("topSongs", () => { + describe("when there is one top song", () => { + it("should return it", async () => { + const artistId = "bobMarleyId"; + const artistName = "Bob Marley"; + const pop = asGenre("Pop"); + + const album1 = anAlbum({ name: "Burnin", genre: pop }); + const artist = anArtist({ + id: artistId, + name: artistName, + albums: [album1], + }); + + const track1 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album1), + genre: pop, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getTopSongsJson([track1]))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album1, []))) + ); + + const result = await generic.topSongs(artistId); + + expect(result).toEqual([track1]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { + params: asURLSearchParams({ + ...authParams, + f: "json", + artist: artistName, + count: 50, + }), + headers, + }); + }); + }); + + describe("when there are many top songs", () => { + it("should return them", async () => { + const artistId = "bobMarleyId"; + const artistName = "Bob Marley"; + + const album1 = anAlbum({ name: "Burnin", genre: POP }); + const album2 = anAlbum({ name: "Churning", genre: POP }); + + const artist = anArtist({ + id: artistId, + name: artistName, + albums: [album1, album2], + }); + + const track1 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album1), + genre: POP, + }); + + const track2 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album2), + genre: POP, + }); + + const track3 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album1), + genre: POP, + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getTopSongsJson([track1, track2, track3]))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album1, []))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album2, []))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album1, []))) + ); + + const result = await generic.topSongs(artistId); + + expect(result).toEqual([track1, track2, track3]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { + params: asURLSearchParams({ + ...authParams, + f: "json", + artist: artistName, + count: 50, + }), + headers, + }); + }); + }); + + describe("when there are no similar songs", () => { + it("should return []", async () => { + const artistId = "bobMarleyId"; + const artistName = "Bob Marley"; + const pop = asGenre("Pop"); + + const album1 = anAlbum({ name: "Burnin", genre: pop }); + const artist = anArtist({ + id: artistId, + name: artistName, + albums: [album1], + }); + + mockGET + + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getTopSongsJson([]))) + ); + + + const result = await generic.topSongs(artistId); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { + params: asURLSearchParams({ + ...authParams, + f: "json", + artist: artistName, + count: 50, + }), + headers, + }); + }); + }); + }); +});