diff --git a/src/subsonic.ts b/src/subsonic.ts index 2035c10..754c517 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -1,4 +1,6 @@ import { option as O } from "fp-ts"; +import * as A from "fp-ts/Array"; +import { ordString } from "fp-ts/lib/Ord"; import { pipe } from "fp-ts/lib/function"; import { Md5 } from "ts-md5"; import { @@ -195,13 +197,15 @@ export type GetTopSongsResponse = { }; export type GetInternetRadioStationsResponse = { - internetRadioStations: { internetRadioStation: { - id: string, - name: string, - streamUrl: string, - homePageUrl?: string }[] - } -} + internetRadioStations: { + internetRadioStation: { + id: string; + name: string; + streamUrl: string; + homePageUrl?: string; + }[]; + }; +}; export type GetSongResponse = { song: song; @@ -274,14 +278,20 @@ export const artistImageURN = ( } }; -export const asTrack = (album: Album, song: song, customPlayers: CustomPlayers): Track => ({ +export const asTrack = ( + album: Album, + song: song, + customPlayers: CustomPlayers +): Track => ({ id: song.id, name: song.title, encoding: pipe( customPlayers.encodingFor({ mimeType: song.contentType }), - O.getOrElse(() => ({ - player: DEFAULT_CLIENT_APPLICATION, - mimeType: song.transcodedContentType ? song.transcodedContentType : song.contentType + O.getOrElse(() => ({ + player: DEFAULT_CLIENT_APPLICATION, + mimeType: song.transcodedContentType + ? song.transcodedContentType + : song.contentType, })) ), duration: song.duration || 0, @@ -327,7 +337,9 @@ export const asGenre = (genreName: string) => ({ name: genreName, }); -export const maybeAsGenre = (genreName: string | undefined): Genre | undefined => +export const maybeAsGenre = ( + genreName: string | undefined +): Genre | undefined => pipe( genreName, O.fromNullable, @@ -340,7 +352,7 @@ export const asYear = (year: string) => ({ }); export interface CustomPlayers { - encodingFor({ mimeType }: { mimeType: string }): O.Option + encodingFor({ mimeType }: { mimeType: string }): O.Option; } export type CustomClient = { @@ -367,21 +379,22 @@ export class TranscodingCustomPlayers implements CustomPlayers { return new TranscodingCustomPlayers(new Map(parts)); } - encodingFor = ({ mimeType }: { mimeType: string }): O.Option => pipe( - this.transcodings.get(mimeType), - O.fromNullable, - O.map(transcodedMimeType => ({ - player:`${DEFAULT_CLIENT_APPLICATION}+${mimeType}`, - mimeType: transcodedMimeType - })) - ) + encodingFor = ({ mimeType }: { mimeType: string }): O.Option => + pipe( + this.transcodings.get(mimeType), + O.fromNullable, + O.map((transcodedMimeType) => ({ + player: `${DEFAULT_CLIENT_APPLICATION}+${mimeType}`, + mimeType: transcodedMimeType, + })) + ); } export const NO_CUSTOM_PLAYERS: CustomPlayers = { encodingFor(_) { - return O.none + return O.none; }, -} +}; export const DEFAULT_CLIENT_APPLICATION = "bonob"; export const USER_AGENT = "bonob"; @@ -674,8 +687,8 @@ export class Subsonic { this.getJSON(credentials, "/rest/getAlbumList2", { type: AlbumQueryTypeToSubsonicType[q.type], ...(q.genre ? { genre: b64Decode(q.genre) } : {}), - ...(q.fromYear ? { fromYear: q.fromYear} : {}), - ...(q.toYear ? { toYear: q.toYear} : {}), + ...(q.fromYear ? { fromYear: q.fromYear } : {}), + ...(q.toYear ? { toYear: q.toYear } : {}), size: 500, offset: q._index, }) @@ -686,11 +699,22 @@ export class Subsonic { total: albums.length == 500 ? total : q._index + albums.length, })); + getGenres = (credentials: Credentials) => + this.getJSON(credentials, "/rest/getGenres").then((it) => + pipe( + it.genres.genre || [], + A.filter((it) => it.albumCount > 0), + A.map((it) => it.value), + A.sort(ordString), + A.map(maybeAsGenre), + A.filter((it) => it != undefined) + ) + ); + // getStarred2 = (credentials: Credentials): Promise<{ albums: Album[] }> => // this.getJSON(credentials, "/rest/getStarred2") // .then((it) => it.starred2) // .then((it) => ({ // albums: it.album.map(asAlbum), // })); - } diff --git a/src/subsonic_music_library.ts b/src/subsonic_music_library.ts index a196870..4f847e5 100644 --- a/src/subsonic_music_library.ts +++ b/src/subsonic_music_library.ts @@ -1,8 +1,4 @@ - - import { option as O, taskEither as TE } from "fp-ts"; -import * as A from "fp-ts/Array"; -import { ordString } from "fp-ts/lib/Ord"; import { pipe } from "fp-ts/lib/function"; import { Credentials, @@ -20,37 +16,34 @@ import { AuthFailure, AuthSuccess, } from "./music_library"; -import { - Subsonic, - CustomPlayers, - GetGenresResponse, - GetAlbumResponse, - asTrack, - asAlbum, - PingResponse, - NO_CUSTOM_PLAYERS, - asToken, - parseToken, - artistImageURN, - USER_AGENT, - GetPlaylistsResponse, - GetPlaylistResponse, - asPlayListSummary, - coverArtURN, - maybeAsGenre, - GetSimilarSongsResponse, - GetTopSongsResponse, - GetInternetRadioStationsResponse, - asYear, +import { + Subsonic, + CustomPlayers, + GetAlbumResponse, + asTrack, + asAlbum, + PingResponse, + NO_CUSTOM_PLAYERS, + asToken, + parseToken, + artistImageURN, + USER_AGENT, + GetPlaylistsResponse, + GetPlaylistResponse, + asPlayListSummary, + coverArtURN, + maybeAsGenre, + GetSimilarSongsResponse, + GetTopSongsResponse, + GetInternetRadioStationsResponse, + asYear, } from "./subsonic"; import _ from "underscore"; import axios from "axios"; -import { b64Encode } from "./b64"; import logger from "./logger"; import { assertSystem, BUrn } from "./burn"; - export class SubsonicMusicService implements MusicService { subsonic: Subsonic; customPlayers: CustomPlayers; @@ -63,7 +56,9 @@ export class SubsonicMusicService implements MusicService { this.customPlayers = customPlayers; } - generateToken = (credentials: Credentials): TE.TaskEither => { + generateToken = ( + credentials: Credentials + ): TE.TaskEither => { const x: TE.TaskEither = TE.tryCatch( () => this.subsonic.getJSON( @@ -71,7 +66,7 @@ export class SubsonicMusicService implements MusicService { "/rest/ping.view" ), (e) => new AuthFailure(e as string) - ) + ); return pipe( x, TE.flatMap(({ type }) => @@ -94,8 +89,8 @@ export class SubsonicMusicService implements MusicService { userId: credentials.username, nickname: credentials.username, })) - ) - } + ); + }; refreshToken = (serviceToken: string) => this.generateToken(parseToken(serviceToken)); @@ -105,7 +100,11 @@ export class SubsonicMusicService implements MusicService { private libraryFor = ( credentials: Credentials & { type: string } ): Promise => { - const genericSubsonic = new SubsonicMusicLibrary(this.subsonic, credentials, this.customPlayers); + const genericSubsonic = new SubsonicMusicLibrary( + this.subsonic, + credentials, + this.customPlayers + ); // return Promise.resolve(genericSubsonic); if (credentials.type == "navidrome") { @@ -125,7 +124,7 @@ export class SubsonicMusicService implements MusicService { ), TE.map((it) => it.data.token as string | undefined) ), - } + }; return Promise.resolve(nd); } else { return Promise.resolve(genericSubsonic); @@ -133,25 +132,25 @@ export class SubsonicMusicService implements MusicService { }; } - export class SubsonicMusicLibrary implements MusicLibrary { subsonic: Subsonic; - credentials: Credentials - customPlayers: CustomPlayers + credentials: Credentials; + customPlayers: CustomPlayers; constructor( subsonic: Subsonic, credentials: Credentials, customPlayers: CustomPlayers ) { - this.subsonic = subsonic - this.credentials = credentials - this.customPlayers = customPlayers + this.subsonic = subsonic; + this.credentials = credentials; + this.customPlayers = customPlayers; } - flavour = () => "subsonic" + flavour = () => "subsonic"; - bearerToken = (_: Credentials) => TE.right(undefined) + bearerToken = (_: Credentials) => + TE.right(undefined); artists = (q: ArtistQuery): Promise> => this.subsonic @@ -164,28 +163,18 @@ export class SubsonicMusicLibrary implements MusicLibrary { name: it.name, image: it.image, })), - })) + })); artist = async (id: string): Promise => - this.subsonic.getArtistWithInfo(this.credentials, id) + this.subsonic.getArtistWithInfo(this.credentials, id); albums = async (q: AlbumQuery): Promise> => - this.subsonic.getAlbumList2(this.credentials, q) + this.subsonic.getAlbumList2(this.credentials, q); - album = (id: string): Promise => this.subsonic.getAlbum(this.credentials, id) + album = (id: string): Promise => + this.subsonic.getAlbum(this.credentials, id); - genres = () => - this.subsonic - .getJSON(this.credentials, "/rest/getGenres") - .then((it) => - pipe( - it.genres.genre || [], - A.filter((it) => it.albumCount > 0), - A.map((it) => it.value), - A.sort(ordString), - A.map((it) => ({ id: b64Encode(it), name: it })) - ) - ) + genres = () => this.subsonic.getGenres(this.credentials); tracks = (albumId: string) => this.subsonic @@ -194,10 +183,13 @@ export class SubsonicMusicLibrary implements MusicLibrary { }) .then((it) => it.album) .then((album) => - (album.song || []).map((song) => asTrack(asAlbum(album), song, this.customPlayers)) - ) + (album.song || []).map((song) => + asTrack(asAlbum(album), song, this.customPlayers) + ) + ); - track = (trackId: string) => this.subsonic.getTrack(this.credentials, trackId) + track = (trackId: string) => + this.subsonic.getTrack(this.credentials, trackId); rate = (trackId: string, rating: Rating) => Promise.resolve(true) @@ -232,189 +224,213 @@ export class SubsonicMusicLibrary implements MusicLibrary { return Promise.all(thingsToUpdate); }) .then(() => true) - .catch(() => false) - - stream = async ({ - trackId, - range, - }: { - trackId: string; - range: string | undefined; - }) => - this.subsonic.getTrack(this.credentials, trackId).then((track) => - this.subsonic - .get( - this.credentials, - `/rest/stream`, - { - id: trackId, - c: track.encoding.player, - }, - { - headers: pipe( - range, - O.fromNullable, - O.map((range) => ({ - "User-Agent": USER_AGENT, - Range: range, - })), - O.getOrElse(() => ({ - "User-Agent": USER_AGENT, - })) - ), - responseType: "stream", - } - ) - .then((stream) => ({ - status: stream.status, - headers: { - "content-type": stream.headers["content-type"], - "content-length": stream.headers["content-length"], - "content-range": stream.headers["content-range"], - "accept-ranges": stream.headers["accept-ranges"], - }, - stream: stream.data, - })) - ) + .catch(() => false); - coverArt = async (coverArtURN: BUrn, size?: number) => - Promise.resolve(coverArtURN) - .then((it) => assertSystem(it, "subsonic")) - .then((it) => this.subsonic.getCoverArt(this.credentials, it.resource.split(":")[1]!, size)) - .then((res) => ({ - contentType: res.headers["content-type"], - data: Buffer.from(res.data, "binary"), + stream = async ({ + trackId, + range, + }: { + trackId: string; + range: string | undefined; + }) => + this.subsonic.getTrack(this.credentials, trackId).then((track) => + this.subsonic + .get( + this.credentials, + `/rest/stream`, + { + id: trackId, + c: track.encoding.player, + }, + { + headers: pipe( + range, + O.fromNullable, + O.map((range) => ({ + "User-Agent": USER_AGENT, + Range: range, + })), + O.getOrElse(() => ({ + "User-Agent": USER_AGENT, + })) + ), + responseType: "stream", + } + ) + .then((stream) => ({ + status: stream.status, + headers: { + "content-type": stream.headers["content-type"], + "content-length": stream.headers["content-length"], + "content-range": stream.headers["content-range"], + "accept-ranges": stream.headers["accept-ranges"], + }, + stream: stream.data, })) - .catch((e) => { - logger.error( - `Failed getting coverArt for urn:'${coverArtURN}': ${e}` - ); - return undefined; - }) + ); - scrobble = async (id: string) => - this.subsonic - .getJSON(this.credentials, `/rest/scrobble`, { - id, - submission: true, - }) - .then((_) => true) - .catch(() => false) - - nowPlaying = async (id: string) => - this.subsonic - .getJSON(this.credentials, `/rest/scrobble`, { - id, - submission: false, - }) - .then((_) => true) - .catch(() => false) - - searchArtists = async (query: string) => - this.subsonic - .search3(this.credentials, { query, artistCount: 20 }) - .then(({ artists }) => - artists.map((artist) => ({ - id: artist.id, - name: artist.name, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: artist.artistImageUrl, - }), - })) - ) - - searchAlbums = async (query: string) => - this.subsonic - .search3(this.credentials, { query, albumCount: 20 }) - .then(({ albums }) => this.subsonic.toAlbumSummary(albums)) - - searchTracks = async (query: string) => - this.subsonic - .search3(this.credentials, { query, songCount: 20 }) - .then(({ songs }) => - Promise.all( - songs.map((it) => this.subsonic.getTrack(this.credentials, it.id)) - ) + coverArt = async (coverArtURN: BUrn, size?: number) => + Promise.resolve(coverArtURN) + .then((it) => assertSystem(it, "subsonic")) + .then((it) => + this.subsonic.getCoverArt( + this.credentials, + it.resource.split(":")[1]!, + 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; + }); - playlists = async () => - this.subsonic - .getJSON(this.credentials, "/rest/getPlaylists") - .then(({ playlists }) => (playlists.playlist || []).map(asPlayListSummary)) + scrobble = async (id: string) => + this.subsonic + .getJSON(this.credentials, `/rest/scrobble`, { + id, + submission: true, + }) + .then((_) => true) + .catch(() => false); - playlist = async (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getPlaylist", { - id, - }) - .then(({ playlist }) => { - let trackNumber = 1; - return { - id: playlist.id, - name: playlist.name, - coverArt: coverArtURN(playlist.coverArt), - 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, - this.customPlayers - ), - number: trackNumber++, - })), - }; - }) + nowPlaying = async (id: string) => + this.subsonic + .getJSON(this.credentials, `/rest/scrobble`, { + id, + submission: false, + }) + .then((_) => true) + .catch(() => false); - createPlaylist = async (name: string) => - this.subsonic - .getJSON(this.credentials, "/rest/createPlaylist", { - name, - }) - .then(({ playlist }) => ({ + searchArtists = async (query: string) => + this.subsonic + .search3(this.credentials, { query, artistCount: 20 }) + .then(({ artists }) => + artists.map((artist) => ({ + id: artist.id, + name: artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), + })) + ); + + searchAlbums = async (query: string) => + this.subsonic + .search3(this.credentials, { query, albumCount: 20 }) + .then(({ albums }) => this.subsonic.toAlbumSummary(albums)); + + searchTracks = async (query: string) => + this.subsonic + .search3(this.credentials, { query, songCount: 20 }) + .then(({ songs }) => + Promise.all( + songs.map((it) => this.subsonic.getTrack(this.credentials, it.id)) + ) + ); + + playlists = async () => + this.subsonic + .getJSON(this.credentials, "/rest/getPlaylists") + .then(({ playlists }) => + (playlists.playlist || []).map(asPlayListSummary) + ); + + playlist = async (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/getPlaylist", { + id, + }) + .then(({ playlist }) => { + let trackNumber = 1; + return { id: playlist.id, name: playlist.name, coverArt: coverArtURN(playlist.coverArt), - })) + 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, + this.customPlayers + ), + number: trackNumber++, + })), + }; + }); - deletePlaylist = async (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/deletePlaylist", { - id, - }) - .then((_) => true) + createPlaylist = async (name: string) => + this.subsonic + .getJSON(this.credentials, "/rest/createPlaylist", { + name, + }) + .then(({ playlist }) => ({ + id: playlist.id, + name: playlist.name, + coverArt: coverArtURN(playlist.coverArt), + })); - addToPlaylist = async (playlistId: string, trackId: string) => - this.subsonic - .getJSON(this.credentials, "/rest/updatePlaylist", { - playlistId, - songIdToAdd: trackId, - }) - .then((_) => true) + deletePlaylist = async (id: string) => + this.subsonic + .getJSON(this.credentials, "/rest/deletePlaylist", { + id, + }) + .then((_) => true); - removeFromPlaylist = async (playlistId: string, indicies: number[]) => - this.subsonic - .getJSON(this.credentials, "/rest/updatePlaylist", { - playlistId, - songIndexToRemove: indicies, - }) - .then((_) => true) + addToPlaylist = async (playlistId: string, trackId: string) => + this.subsonic + .getJSON(this.credentials, "/rest/updatePlaylist", { + playlistId, + songIdToAdd: trackId, + }) + .then((_) => true); - similarSongs = async (id: string) => - this.subsonic - .getJSON( - this.credentials, - "/rest/getSimilarSongs2", - { id, count: 50 } + removeFromPlaylist = async (playlistId: string, indicies: number[]) => + this.subsonic + .getJSON(this.credentials, "/rest/updatePlaylist", { + playlistId, + songIndexToRemove: indicies, + }) + .then((_) => true); + + similarSongs = async (id: string) => + this.subsonic + .getJSON( + this.credentials, + "/rest/getSimilarSongs2", + { id, count: 50 } + ) + .then((it) => it.similarSongs2.song || []) + .then((songs) => + Promise.all( + songs.map((song) => + this.subsonic + .getAlbum(this.credentials, song.albumId!) + .then((album) => asTrack(album, song, this.customPlayers)) + ) ) - .then((it) => it.similarSongs2.song || []) + ); + + topSongs = async (artistId: string) => + this.subsonic.getArtist(this.credentials, artistId).then(({ name }) => + this.subsonic + .getJSON(this.credentials, "/rest/getTopSongs", { + artist: name, + count: 50, + }) + .then((it) => it.topSongs.song || []) .then((songs) => Promise.all( songs.map((song) => @@ -424,60 +440,45 @@ export class SubsonicMusicLibrary implements MusicLibrary { ) ) ) + ); - topSongs = async (artistId: string) => - this.subsonic.getArtist(this.credentials, artistId).then(({ name }) => - this.subsonic - .getJSON(this.credentials, "/rest/getTopSongs", { - artist: name, - count: 50, - }) - .then((it) => it.topSongs.song || []) - .then((songs) => - Promise.all( - songs.map((song) => - this.subsonic - .getAlbum(this.credentials, song.albumId!) - .then((album) => asTrack(album, song, this.customPlayers)) - ) - ) - ) - ) - - radioStations = async () => this.subsonic + radioStations = async () => + this.subsonic .getJSON( this.credentials, "/rest/getInternetRadioStations" ) .then((it) => it.internetRadioStations.internetRadioStation || []) - .then((stations) => stations.map((it) => ({ - id: it.id, - name: it.name, - url: it.streamUrl, - homePage: it.homePageUrl - }))) + .then((stations) => + stations.map((it) => ({ + id: it.id, + name: it.name, + url: it.streamUrl, + homePage: it.homePageUrl, + })) + ); - radioStation = async (id: string) => this.radioStations() - .then(it => - it.find(station => station.id === id)! - ) + radioStation = async (id: string) => + this.radioStations().then((it) => it.find((station) => station.id === id)!); years = async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100000, // FIXME: better than this, probably doesnt work anyway as max _count is 500 or something - type: "alphabeticalByArtist", - }; - const years = this.subsonic.getAlbumList2(this.credentials, q) - .then(({ results }) => - results.map((album) => album.year || "?") - .filter((item, i, ar) => ar.indexOf(item) === i) - .sort() - .map((year) => ({ - ...asYear(year) - })) - .reverse() - ); - return years; - } + const q: AlbumQuery = { + _index: 0, + _count: 100000, // FIXME: better than this, probably doesnt work anyway as max _count is 500 or something + type: "alphabeticalByArtist", + }; + const years = this.subsonic + .getAlbumList2(this.credentials, q) + .then(({ results }) => + results + .map((album) => album.year || "?") + .filter((item, i, ar) => ar.indexOf(item) === i) + .sort() + .map((year) => ({ + ...asYear(year), + })) + .reverse() + ); + return years; + }; } diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 168d8a3..111411c 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -1,10 +1,21 @@ +import { v4 as uuid } from "uuid"; import { Md5 } from "ts-md5"; import tmp from "tmp"; import fse from "fs-extra"; import path from "path"; -import { pipe } from "fp-ts/lib/function"; +import { pipe } from "fp-ts/lib/function"; import { option as O } from "fp-ts"; +import sharp from "sharp"; +jest.mock("sharp"); + +import axios from "axios"; +jest.mock("axios"); + +import randomstring from "randomstring"; +jest.mock("randomstring"); + +import { URLBuilder } from "../src/url_builder"; import { isValidImage, t, @@ -17,20 +28,13 @@ import { TranscodingCustomPlayers, CustomPlayers, NO_CUSTOM_PLAYERS, + Subsonic, } from "../src/subsonic"; -import sharp from "sharp"; -jest.mock("sharp"); +import { b64Encode } from "../src/b64"; -import { - Album, - Artist, - Track, -} from "../src/music_library"; -import { - anAlbum, - aTrack, -} from "./builders"; +import { Album, Artist, Track } from "../src/music_library"; +import { anAlbum, aTrack } from "./builders"; import { BUrn } from "../src/burn"; describe("t", () => { @@ -61,26 +65,33 @@ describe("isValidImage", () => { }); }); - describe("StreamClient(s)", () => { describe("CustomStreamClientApplications", () => { - const customClients = TranscodingCustomPlayers.from("audio/flac,audio/mp3>audio/ogg") - + const customClients = TranscodingCustomPlayers.from( + "audio/flac,audio/mp3>audio/ogg" + ); + describe("clientFor", () => { describe("when there is a match", () => { it("should return the match", () => { - expect(customClients.encodingFor({ mimeType: "audio/flac" })).toEqual(O.of({player: "bonob+audio/flac", mimeType:"audio/flac"})) - expect(customClients.encodingFor({ mimeType: "audio/mp3" })).toEqual(O.of({player: "bonob+audio/mp3", mimeType:"audio/ogg"})) + expect(customClients.encodingFor({ mimeType: "audio/flac" })).toEqual( + O.of({ player: "bonob+audio/flac", mimeType: "audio/flac" }) + ); + expect(customClients.encodingFor({ mimeType: "audio/mp3" })).toEqual( + O.of({ player: "bonob+audio/mp3", mimeType: "audio/ogg" }) + ); }); }); - + describe("when there is no match", () => { it("should return undefined", () => { - expect(customClients.encodingFor({ mimeType: "audio/bob" })).toEqual(O.none) + expect(customClients.encodingFor({ mimeType: "audio/bob" })).toEqual( + O.none + ); }); }); }); - }); + }); }); describe("asURLSearchParams", () => { @@ -197,12 +208,13 @@ describe("cachingImageFetcher", () => { }); }); -const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => pipe( - coverArt, - O.fromNullable, - O.map(it => it.resource.split(":")[1]), - O.getOrElseW(() => "") -) +const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => + pipe( + coverArt, + O.fromNullable, + O.map((it) => it.resource.split(":")[1]), + O.getOrElseW(() => "") + ); const asSongJson = (track: Track) => ({ id: track.id, @@ -241,8 +253,14 @@ describe("artistURN", () => { describe("a valid external URL", () => { it("should return an external URN", () => { expect( - artistImageURN({ artistId: "someArtistId", artistImageURL: "http://example.com/image.jpg" }) - ).toEqual({ system: "external", resource: "http://example.com/image.jpg" }); + artistImageURN({ + artistId: "someArtistId", + artistImageURL: "http://example.com/image.jpg", + }) + ).toEqual({ + system: "external", + resource: "http://example.com/image.jpg", + }); }); }); @@ -252,7 +270,7 @@ describe("artistURN", () => { expect( artistImageURN({ artistId: "someArtistId", - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}`, }) ).toEqual({ system: "subsonic", resource: "art:someArtistId" }); }); @@ -263,7 +281,7 @@ describe("artistURN", () => { expect( artistImageURN({ artistId: "-1", - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}`, }) ).toBeUndefined(); }); @@ -274,7 +292,7 @@ describe("artistURN", () => { expect( artistImageURN({ artistId: undefined, - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}`, }) ).toBeUndefined(); }); @@ -284,19 +302,28 @@ describe("artistURN", () => { describe("undefined", () => { describe("and artistId is valid", () => { it("should return artist art by artist id URN", () => { - expect(artistImageURN({ artistId: "someArtistId", artistImageURL: undefined })).toEqual({system:"subsonic", resource:"art:someArtistId"}); + expect( + artistImageURN({ + artistId: "someArtistId", + artistImageURL: undefined, + }) + ).toEqual({ system: "subsonic", resource: "art:someArtistId" }); }); }); describe("and artistId is -1", () => { it("should return error icon", () => { - expect(artistImageURN({ artistId: "-1", artistImageURL: undefined })).toBeUndefined(); + expect( + artistImageURN({ artistId: "-1", artistImageURL: undefined }) + ).toBeUndefined(); }); }); describe("and artistId is undefined", () => { it("should return error icon", () => { - expect(artistImageURN({ artistId: undefined, artistImageURL: undefined })).toBeUndefined(); + expect( + artistImageURN({ artistId: undefined, artistImageURL: undefined }) + ).toBeUndefined(); }); }); }); @@ -311,10 +338,20 @@ describe("asTrack", () => { describe("when the song has no artistId", () => { const album = anAlbum(); - const track = aTrack({ artist: { id: undefined, name: "Not in library so no id", image: undefined }}); + const track = aTrack({ + artist: { + id: undefined, + name: "Not in library so no id", + image: undefined, + }, + }); it("should provide no artistId", () => { - const result = asTrack(album, { ...asSongJson(track) }, NO_CUSTOM_PLAYERS); + const result = asTrack( + album, + { ...asSongJson(track) }, + NO_CUSTOM_PLAYERS + ); expect(result.artist.id).toBeUndefined(); expect(result.artist.name).toEqual("Not in library so no id"); expect(result.artist.image).toBeUndefined(); @@ -325,7 +362,11 @@ describe("asTrack", () => { const album = anAlbum(); it("should provide a ? to sonos", () => { - const result = asTrack(album, { id: '1' } as any as song, NO_CUSTOM_PLAYERS); + const result = asTrack( + album, + { id: "1" } as any as song, + NO_CUSTOM_PLAYERS + ); expect(result.artist.id).toBeUndefined(); expect(result.artist.name).toEqual("?"); expect(result.artist.image).toBeUndefined(); @@ -338,14 +379,22 @@ describe("asTrack", () => { describe("a value greater than 5", () => { it("should be returned as 0", () => { - const result = asTrack(album, { ...asSongJson(track), userRating: 6 }, NO_CUSTOM_PLAYERS); + const result = asTrack( + album, + { ...asSongJson(track), userRating: 6 }, + NO_CUSTOM_PLAYERS + ); 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 }, NO_CUSTOM_PLAYERS); + const result = asTrack( + album, + { ...asSongJson(track), userRating: -1 }, + NO_CUSTOM_PLAYERS + ); expect(result.rating.stars).toEqual(0); }); }); @@ -358,82 +407,281 @@ describe("asTrack", () => { describe("when there are no custom players", () => { describe("when subsonic reports no transcodedContentType", () => { it("should use the default client and default contentType", () => { - const result = asTrack(album, { - ...asSongJson(track), - contentType: "nonTranscodedContentType", - transcodedContentType: undefined - }, NO_CUSTOM_PLAYERS); + const result = asTrack( + album, + { + ...asSongJson(track), + contentType: "nonTranscodedContentType", + transcodedContentType: undefined, + }, + NO_CUSTOM_PLAYERS + ); - expect(result.encoding).toEqual({ player: "bonob", mimeType: "nonTranscodedContentType" }) + expect(result.encoding).toEqual({ + player: "bonob", + mimeType: "nonTranscodedContentType", + }); }); }); describe("when subsonic reports a transcodedContentType", () => { it("should use the default client and transcodedContentType", () => { - const result = asTrack(album, { - ...asSongJson(track), - contentType: "nonTranscodedContentType", - transcodedContentType: "transcodedContentType" - }, NO_CUSTOM_PLAYERS); + const result = asTrack( + album, + { + ...asSongJson(track), + contentType: "nonTranscodedContentType", + transcodedContentType: "transcodedContentType", + }, + NO_CUSTOM_PLAYERS + ); - expect(result.encoding).toEqual({ player: "bonob", mimeType: "transcodedContentType" }) + expect(result.encoding).toEqual({ + player: "bonob", + mimeType: "transcodedContentType", + }); }); }); }); describe("when there are custom players registered", () => { const streamClient = { - encodingFor: jest.fn() - } + encodingFor: jest.fn(), + }; describe("however no player is found for the default mimeType", () => { describe("and there is no transcodedContentType", () => { it("should use the default player with the default content type", () => { - streamClient.encodingFor.mockReturnValue(O.none) + streamClient.encodingFor.mockReturnValue(O.none); - const result = asTrack(album, { - ...asSongJson(track), - contentType: "nonTranscodedContentType", - transcodedContentType: undefined - }, streamClient as unknown as CustomPlayers); - - expect(result.encoding).toEqual({ player: "bonob", mimeType: "nonTranscodedContentType" }); - expect(streamClient.encodingFor).toHaveBeenCalledWith({ mimeType: "nonTranscodedContentType" }); + const result = asTrack( + album, + { + ...asSongJson(track), + contentType: "nonTranscodedContentType", + transcodedContentType: undefined, + }, + streamClient as unknown as CustomPlayers + ); + + expect(result.encoding).toEqual({ + player: "bonob", + mimeType: "nonTranscodedContentType", + }); + expect(streamClient.encodingFor).toHaveBeenCalledWith({ + mimeType: "nonTranscodedContentType", + }); }); }); describe("and there is a transcodedContentType", () => { it("should use the default player with the transcodedContentType", () => { - streamClient.encodingFor.mockReturnValue(O.none) + streamClient.encodingFor.mockReturnValue(O.none); - const result = asTrack(album, { - ...asSongJson(track), - contentType: "nonTranscodedContentType", - transcodedContentType: "transcodedContentType1" - }, streamClient as unknown as CustomPlayers); - - expect(result.encoding).toEqual({ player: "bonob", mimeType: "transcodedContentType1" }); - expect(streamClient.encodingFor).toHaveBeenCalledWith({ mimeType: "nonTranscodedContentType" }); + const result = asTrack( + album, + { + ...asSongJson(track), + contentType: "nonTranscodedContentType", + transcodedContentType: "transcodedContentType1", + }, + streamClient as unknown as CustomPlayers + ); + + expect(result.encoding).toEqual({ + player: "bonob", + mimeType: "transcodedContentType1", + }); + expect(streamClient.encodingFor).toHaveBeenCalledWith({ + mimeType: "nonTranscodedContentType", + }); }); }); }); describe("there is a player with the matching content type", () => { it("should use it", () => { - const customEncoding = { player: "custom-player", mimeType: "audio/some-mime-type" }; + const customEncoding = { + player: "custom-player", + mimeType: "audio/some-mime-type", + }; streamClient.encodingFor.mockReturnValue(O.of(customEncoding)); - - const result = asTrack(album, { - ...asSongJson(track), - contentType: "sourced-from/subsonic", - transcodedContentType: "sourced-from/subsonic2" - }, streamClient as unknown as CustomPlayers); - + + const result = asTrack( + album, + { + ...asSongJson(track), + contentType: "sourced-from/subsonic", + transcodedContentType: "sourced-from/subsonic2", + }, + streamClient as unknown as CustomPlayers + ); + expect(result.encoding).toEqual(customEncoding); - expect(streamClient.encodingFor).toHaveBeenCalledWith({ mimeType: "sourced-from/subsonic" }); - }); + expect(streamClient.encodingFor).toHaveBeenCalledWith({ + mimeType: "sourced-from/subsonic", + }); + }); }); }); }); }); +const subsonicOK = (body: any = {}) => ({ + "subsonic-response": { + status: "ok", + version: "1.16.1", + type: "subsonic", + serverVersion: "0.45.1 (c55e6590)", + ...body, + }, +}); + +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 ok = (data: string | object) => ({ + status: 200, + data, +}); + +describe("subsonic", () => { + const url = new URLBuilder("http://127.0.0.22:4567/some-context-path"); + const customPlayers = { + encodingFor: jest.fn(), + }; + const username = `user1-${uuid()}`; + const password = `pass1-${uuid()}`; + const credentials = { username, password }; + const subsonic = new Subsonic(url, customPlayers); + + const mockRandomstring = jest.fn(); + const mockGET = jest.fn(); + const mockPOST = jest.fn(); + + const salt = "saltysalty"; + + 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", + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + + randomstring.generate = mockRandomstring; + axios.get = mockGET; + axios.post = mockPOST; + + mockRandomstring.mockReturnValue(salt); + }); + + describe("getting genres", () => { + describe("when there are none", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson([]))) + ); + }); + + it("should return empty array", async () => { + const result = await subsonic.getGenres(credentials); + + expect(result).toEqual([]); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getGenres" }).href(), + { + 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 subsonic.getGenres(credentials); + + expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getGenres" }).href(), + { + 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 subsonic.getGenres(credentials); + + 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.append({ pathname: "/rest/getGenres" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + }); + }); + }); +}); diff --git a/tests/subsonic_music_library.test.ts b/tests/subsonic_music_library.test.ts index 9bc3098..5faa5d0 100644 --- a/tests/subsonic_music_library.test.ts +++ b/tests/subsonic_music_library.test.ts @@ -1,7 +1,13 @@ import { v4 as uuid } from "uuid"; -import { pipe } from "fp-ts/lib/function"; +import { pipe } from "fp-ts/lib/function"; import { option as O, taskEither as TE, task as T, either as E } from "fp-ts"; +import axios from "axios"; +jest.mock("axios"); + +import randomstring from "randomstring"; +jest.mock("randomstring"); + import { Subsonic, t, @@ -16,15 +22,9 @@ import { } from "../src/subsonic"; import { - SubsonicMusicService, - SubsonicMusicLibrary - } from "../src/subsonic_music_library"; - -import axios from "axios"; -jest.mock("axios"); - -import randomstring from "randomstring"; -jest.mock("randomstring"); + SubsonicMusicService, + SubsonicMusicLibrary, +} from "../src/subsonic_music_library"; import { Album, @@ -52,38 +52,35 @@ import { aTrack, POP, ROCK, - aRadioStation + aRadioStation, } from "./builders"; import { b64Encode } from "../src/b64"; import { BUrn } from "../src/burn"; import { URLBuilder } from "../src/url_builder"; - const EMPTY = { - "subsonic-response": { - status: "ok", - version: "1.16.1", - type: "subsonic", - serverVersion: "0.45.1 (c55e6590)", - }, - }; - - const FAILURE = { - "subsonic-response": { - status: "failed", - version: "1.16.1", - type: "subsonic", - serverVersion: "0.45.1 (c55e6590)", - error: { code: 10, message: 'Missing required parameter "v"' }, - }, - }; - - -const ok = (data: string | object) => ({ - status: 200, - data, - }); + "subsonic-response": { + status: "ok", + version: "1.16.1", + type: "subsonic", + serverVersion: "0.45.1 (c55e6590)", + }, +}; +const FAILURE = { + "subsonic-response": { + status: "failed", + version: "1.16.1", + type: "subsonic", + serverVersion: "0.45.1 (c55e6590)", + error: { code: 10, message: 'Missing required parameter "v"' }, + }, +}; + +const ok = (data: string | object) => ({ + status: 200, + data, +}); const pingJson = (pingResponse: Partial = {}) => ({ "subsonic-response": { @@ -91,67 +88,58 @@ const pingJson = (pingResponse: Partial = {}) => ({ version: "1.16.1", type: "subsonic", serverVersion: "0.45.1 (c55e6590)", - ...pingResponse - } -}) + ...pingResponse, + }, +}); const PING_OK = pingJson({ status: "ok" }); const error = (code: string, message: string) => ({ - "subsonic-response": { - status: "failed", - version: "1.16.1", - type: "subsonic", - serverVersion: "0.45.1 (c55e6590)", - error: { code, message }, - }, - }); - + "subsonic-response": { + status: "failed", + version: "1.16.1", + type: "subsonic", + serverVersion: "0.45.1 (c55e6590)", + error: { code, message }, + }, +}); - 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 getGenresJson = (genres: { name: string; albumCount: number }[]) => - subsonicOK({ - genres: { - genre: genres.map(asGenreJson), - }, - }); - - 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 getAlbumJson = (artist: Artist, album: Album, tracks: Track[]) => - subsonicOK({ album: asAlbumJson(artist, album, tracks) }); - - const getSongJson = (track: Track) => subsonicOK({ song: asSongJson(track) }); +const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => + pipe( + coverArt, + O.fromNullable, + O.map((it) => it.resource.split(":")[1]), + O.getOrElseW(() => "") + ); +const getAlbumJson = (artist: Artist, album: Album, tracks: Track[]) => + subsonicOK({ album: asAlbumJson(artist, album, tracks) }); + +const getSongJson = (track: Track) => subsonicOK({ song: asSongJson(track) }); const asArtistJson = ( artist: Artist, @@ -164,69 +152,64 @@ const asArtistJson = ( ...extras, }); -const getArtistJson = (artist: Artist, extras: ArtistExtras = { artistImageUrl: undefined }) => - subsonicOK({ - artist: asArtistJson(artist, extras), - }); - - const getRadioStationsJson = (radioStations: RadioStation[]) => - subsonicOK({ - internetRadioStations: { - internetRadioStation: radioStations.map((it) => ({ - id: it.id, - name: it.name, - streamUrl: it.url, - homePageUrl: it.homePage - })) - }, - }); - - const asGenreJson = (genre: { name: string; albumCount: number }) => ({ - songCount: 1475, - albumCount: genre.albumCount, - value: genre.name, +const getArtistJson = ( + artist: Artist, + extras: ArtistExtras = { artistImageUrl: undefined } +) => + subsonicOK({ + artist: asArtistJson(artist, extras), }); - - - // const getStarredJson = ({ albums }: { albums: Album[] }) => subsonicOK({starred2: { - // album: albums.map(it => asAlbumJson({ id: it.artistId, name: it.artistName }, it, [])), - // song: [], - // }}) - - const subsonicOK = (body: any = {}) => ({ - "subsonic-response": { - status: "ok", - version: "1.16.1", - type: "subsonic", - serverVersion: "0.45.1 (c55e6590)", - ...body, + +const getRadioStationsJson = (radioStations: RadioStation[]) => + subsonicOK({ + internetRadioStations: { + internetRadioStation: radioStations.map((it) => ({ + id: it.id, + name: it.name, + streamUrl: it.url, + homePageUrl: it.homePage, + })), }, }); - - const getSimilarSongsJson = (tracks: Track[]) => - subsonicOK({ similarSongs2: { song: tracks.map(asSongJson) } }); - - const getTopSongsJson = (tracks: Track[]) => - subsonicOK({ topSongs: { song: tracks.map(asSongJson) } }); - 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 getStarredJson = ({ albums }: { albums: Album[] }) => subsonicOK({starred2: { +// album: albums.map(it => asAlbumJson({ id: it.artistId, name: it.artistName }, it, [])), +// song: [], +// }}) - export type ArtistWithAlbum = { - artist: Artist; - album: Album; - }; - +const subsonicOK = (body: any = {}) => ({ + "subsonic-response": { + status: "ok", + version: "1.16.1", + type: "subsonic", + serverVersion: "0.45.1 (c55e6590)", + ...body, + }, +}); -type ArtistExtras = { artistImageUrl: string | undefined } +const getSimilarSongsJson = (tracks: Track[]) => + subsonicOK({ similarSongs2: { song: tracks.map(asSongJson) } }); + +const getTopSongsJson = (tracks: Track[]) => + subsonicOK({ topSongs: { song: tracks.map(asSongJson) } }); + +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", +}); + +export type ArtistWithAlbum = { + artist: Artist; + album: Album; +}; + +type ArtistExtras = { artistImageUrl: string | undefined }; const asSongJson = (track: Track) => ({ id: track.id, @@ -256,1317 +239,1463 @@ const asSongJson = (track: Track) => ({ }); const getArtistInfoJson = ( - artist: Artist, - images: images = { - smallImageUrl: undefined, - mediumImageUrl: undefined, - largeImageUrl: undefined, + artist: Artist, + images: images = { + smallImageUrl: undefined, + mediumImageUrl: undefined, + largeImageUrl: undefined, + } +) => + subsonicOK({ + artistInfo2: { + ...images, + similarArtist: artist.similarArtists.map(asSimilarArtistJson), + }, + }); + +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 getPlayListsJson = (playlists: PlaylistSummary[]) => + subsonicOK({ + playlists: { + playlist: playlists.map(asPlaylistJson), + }, + }); + +const createPlayListJson = (playlist: PlaylistSummary) => + subsonicOK({ + playlist: asPlaylistJson(playlist), + }); + +const getAlbumListJson = (albums: [Artist, Album][]) => + subsonicOK({ + albumList2: { + album: albums.map(([artist, album]) => asAlbumJson(artist, album)), + }, + }); + +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.encoding.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; } - ) => - subsonicOK({ - artistInfo2: { - ...images, - similarArtist: artist.similarArtists.map(asSimilarArtistJson), - }, - }); - - 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 getPlayListsJson = (playlists: PlaylistSummary[]) => - subsonicOK({ - playlists: { - playlist: playlists.map(asPlaylistJson), - }, - }); - - const createPlayListJson = (playlist: PlaylistSummary) => - subsonicOK({ - playlist: asPlaylistJson(playlist), + 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("SubsonicMusicService", () => { + const url = new URLBuilder("http://127.0.0.22:4567/some-context-path"); + const username = `user1-${uuid()}`; + const password = `pass1-${uuid()}`; + const salt = "saltysalty"; + + const customPlayers = { + encodingFor: jest.fn(), + }; + + const subsonic = new SubsonicMusicService( + new Subsonic(url, customPlayers), + customPlayers as unknown as CustomPlayers + ); + + const mockRandomstring = jest.fn(); + const mockGET = jest.fn(); + const mockPOST = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + + randomstring.generate = mockRandomstring; + axios.get = mockGET; + axios.post = mockPOST; + + mockRandomstring.mockReturnValue(salt); + }); + + const authParams = { + u: username, + v: "1.16.1", + c: "bonob", + t: t(password, salt), + s: salt, + }; + + const authParamsPlusJson = { + ...authParams, + f: "json", + }; + + const headers = { + "User-Agent": "bonob", + }; + + const tokenFor = (credentials: Credentials) => + pipe( + subsonic.generateToken(credentials), + TE.fold((e) => { + throw e; + }, T.of) + ); + + describe("generateToken", () => { + describe("when the credentials are valid", () => { + describe("when the backend is generic subsonic", () => { + it("should be able to generate a token and then login using it", async () => { + (axios.get as jest.Mock).mockResolvedValue(ok(PING_OK)); + + const token = await tokenFor({ + username, + password, + })(); + + expect(token.serviceToken).toBeDefined(); + expect(token.nickname).toEqual(username); + expect(token.userId).toEqual(username); + + expect(parseToken(token.serviceToken)).toEqual({ + username, + password, + type: PING_OK["subsonic-response"].type, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/ping.view" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + }); + + it("should store the type of the subsonic server on the token", async () => { + const type = "someSubsonicClone"; + (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type }))); + + const token = await tokenFor({ + username, + password, + })(); + + expect(token.serviceToken).toBeDefined(); + expect(token.nickname).toEqual(username); + expect(token.userId).toEqual(username); + + expect(parseToken(token.serviceToken)).toEqual({ + username, + password, + type, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/ping.view" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + }); + }); + + describe("when the backend is navidrome", () => { + it("should login to nd and get the nd bearer token", async () => { + const navidromeToken = `nd-${uuid()}`; + + (axios.get as jest.Mock).mockResolvedValue( + ok(pingJson({ type: "navidrome" })) + ); + (axios.post as jest.Mock).mockResolvedValue( + ok({ token: navidromeToken }) + ); + + const token = await tokenFor({ + username, + password, + })(); + + expect(token.serviceToken).toBeDefined(); + expect(token.nickname).toEqual(username); + expect(token.userId).toEqual(username); + + expect(parseToken(token.serviceToken)).toEqual({ + username, + password, + type: "navidrome", + bearer: navidromeToken, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/ping.view" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + expect(axios.post).toHaveBeenCalledWith( + url.append({ pathname: "/auth/login" }).href(), + { + username, + password, + } + ); + }); + }); }); - const getAlbumListJson = (albums: [Artist, Album][]) => - subsonicOK({ - albumList2: { - album: albums.map(([artist, album]) => asAlbumJson(artist, album)), + describe("when the credentials are not valid", () => { + it("should be able to generate a token and then login using it", async () => { + (axios.get as jest.Mock).mockResolvedValue({ + status: 200, + data: error("40", "Wrong username or password"), + }); + + const token = await subsonic.generateToken({ username, password })(); + expect(token).toEqual( + E.left(new AuthFailure("Subsonic error:Wrong username or password")) + ); + }); + }); + }); + + describe("refreshToken", () => { + describe("when the credentials are valid", () => { + describe("when the backend is generic subsonic", () => { + it("should be able to generate a token and then login using it", async () => { + const type = `subsonic-clone-${uuid()}`; + (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type }))); + + const credentials = { + username, + password, + type: "foo", + bearer: undefined, + }; + const originalToken = asToken(credentials); + + const refreshedToken = await pipe( + subsonic.refreshToken(originalToken), + TE.fold((e) => { + throw e; + }, T.of) + )(); + + expect(refreshedToken.serviceToken).toBeDefined(); + expect(refreshedToken.nickname).toEqual(credentials.username); + expect(refreshedToken.userId).toEqual(credentials.username); + + expect(parseToken(refreshedToken.serviceToken)).toEqual({ + username, + password, + type, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/ping.view" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + }); + }); + + describe("when the backend is navidrome", () => { + it("should login to nd and get the nd bearer token", async () => { + const navidromeToken = `nd-${uuid()}`; + + (axios.get as jest.Mock).mockResolvedValue( + ok(pingJson({ type: "navidrome" })) + ); + (axios.post as jest.Mock).mockResolvedValue( + ok({ token: navidromeToken }) + ); + + const credentials = { + username, + password, + type: "navidrome", + bearer: undefined, + }; + const originalToken = asToken(credentials); + + const refreshedToken = await pipe( + subsonic.refreshToken(originalToken), + TE.fold((e) => { + throw e; + }, T.of) + )(); + + expect(refreshedToken.serviceToken).toBeDefined(); + expect(refreshedToken.nickname).toEqual(username); + expect(refreshedToken.userId).toEqual(username); + + expect(parseToken(refreshedToken.serviceToken)).toEqual({ + username, + password, + type: "navidrome", + bearer: navidromeToken, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/ping.view" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + expect(axios.post).toHaveBeenCalledWith( + url.append({ pathname: "/auth/login" }).href(), + { + username, + password, + } + ); + }); + }); + }); + + describe("when the credentials are not valid", () => { + it("should be able to generate a token and then login using it", async () => { + (axios.get as jest.Mock).mockResolvedValue({ + status: 200, + data: error("40", "Wrong username or password"), + }); + + const credentials = { + username, + password, + type: "foo", + bearer: undefined, + }; + const originalToken = asToken(credentials); + + const token = await subsonic.refreshToken(originalToken)(); + expect(token).toEqual( + E.left(new AuthFailure("Subsonic error:Wrong username or password")) + ); + }); + }); + }); + + describe("login", () => { + describe("when the token is for generic subsonic", () => { + it("should return a subsonic client", async () => { + const client = await subsonic.login( + asToken({ + username: "foo", + password: "bar", + type: "subsonic", + bearer: undefined, + }) + ); + expect(client.flavour()).toEqual("subsonic"); + }); + }); + + describe("when the token is for navidrome", () => { + it("should return a navidrome client", async () => { + const client = await subsonic.login( + asToken({ + username: "foo", + password: "bar", + type: "navidrome", + bearer: undefined, + }) + ); + expect(client.flavour()).toEqual("navidrome"); + }); + }); + + describe("when the token is for gonic", () => { + it("should return a subsonic client", async () => { + const client = await subsonic.login( + asToken({ + username: "foo", + password: "bar", + type: "gonic", + bearer: undefined, + }) + ); + expect(client.flavour()).toEqual("subsonic"); + }); + }); + }); + + describe("bearerToken", () => { + describe("when flavour is generic subsonic", () => { + it("should return undefined", async () => { + const credentials = { username: "foo", password: "bar" }; + const token = { ...credentials, type: "subsonic", bearer: undefined }; + const client = await subsonic.login(asToken(token)); + + const bearerToken = await pipe(client.bearerToken(credentials))(); + expect(bearerToken).toStrictEqual(E.right(undefined)); + }); + }); + + describe("when flavour is navidrome", () => { + it("should get a bearerToken from navidrome", async () => { + const credentials = { username: "foo", password: "bar" }; + const token = { ...credentials, type: "navidrome", bearer: undefined }; + const client = await subsonic.login(asToken(token)); + + mockPOST.mockImplementationOnce(() => + Promise.resolve(ok({ token: "theBearerToken" })) + ); + + const bearerToken = await pipe(client.bearerToken(credentials))(); + expect(bearerToken).toStrictEqual(E.right("theBearerToken")); + + expect(axios.post).toHaveBeenCalledWith( + url.append({ pathname: "/auth/login" }).href(), + credentials + ); + }); + }); + }); +}); + +describe("SubsonicMusicLibrary", () => { + const url = new URLBuilder("http://127.0.0.22:4567/some-context-path"); + const username = `user1-${uuid()}`; + const password = `pass1-${uuid()}`; + const salt = "saltysalty"; + + const customPlayers = { + encodingFor: jest.fn(), + }; + + const subsonic = new SubsonicMusicLibrary( + new Subsonic(url, customPlayers), + { username, password }, + customPlayers as unknown as CustomPlayers + ); + + const mockRandomstring = jest.fn(); + const mockGET = jest.fn(); + const mockPOST = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + + randomstring.generate = mockRandomstring; + axios.get = mockGET; + axios.post = mockPOST; + + mockRandomstring.mockReturnValue(salt); + }); + + 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 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 subsonic.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.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + 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 subsonic.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.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + 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 subsonic.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.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + 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 subsonic.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.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + 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 subsonic.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.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + 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 subsonic.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.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + 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 subsonic.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.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + 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 subsonic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: artist.image, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + 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 subsonic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: artist.image, + albums: artist.albums, + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + 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 subsonic.artist(artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + image: artist.image, + albums: [], + similarArtists: [], + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + } + ); + }); + }); + }); + }); + + describe("getting artists", () => { + describe("when there are indexes, but no artists", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve( + ok( + subsonicOK({ + artists: { + index: [ + { + name: "#", + }, + { + name: "A", + }, + { + name: "B", + }, + ], + }, + }) + ) + ) + ); + }); + + it("should return empty", async () => { + const artists = await subsonic.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 subsonic.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, + }, + ], + }, + ], }, }); - - - - 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.encoding.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("SubsonicMusicService", () => { - const url = new URLBuilder("http://127.0.0.22:4567/some-context-path"); - const username = `user1-${uuid()}`; - const password = `pass1-${uuid()}`; - const salt = "saltysalty"; - - const customPlayers = { - encodingFor: jest.fn() - }; - - const subsonic = new SubsonicMusicService( - new Subsonic(url, customPlayers), - customPlayers as unknown as CustomPlayers - ); - - const mockRandomstring = jest.fn(); - const mockGET = jest.fn(); - const mockPOST = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - jest.resetAllMocks(); - - randomstring.generate = mockRandomstring; - axios.get = mockGET; - axios.post = mockPOST; - - mockRandomstring.mockReturnValue(salt); - }); - - const authParams = { - u: username, - v: "1.16.1", - c: "bonob", - t: t(password, salt), - s: salt, - }; - - const authParamsPlusJson = { - ...authParams, - f: "json", - }; - - const headers = { - "User-Agent": "bonob", - }; - - - const tokenFor = (credentials: Credentials) => pipe( - subsonic.generateToken(credentials), - TE.fold(e => { throw e }, T.of) - ) - - - describe("generateToken", () => { - describe("when the credentials are valid", () => { - describe("when the backend is generic subsonic", () => { - it("should be able to generate a token and then login using it", async () => { - (axios.get as jest.Mock).mockResolvedValue(ok(PING_OK)); - - const token = await tokenFor({ - username, - password, - })() - - expect(token.serviceToken).toBeDefined(); - expect(token.nickname).toEqual(username); - expect(token.userId).toEqual(username); - - expect(parseToken(token.serviceToken)).toEqual({ username, password, type: PING_OK["subsonic-response"].type }) - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/ping.view' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - - it("should store the type of the subsonic server on the token", async () => { - const type = "someSubsonicClone"; - (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type }))); - - const token = await tokenFor({ - username, - password, - })() - - expect(token.serviceToken).toBeDefined(); - expect(token.nickname).toEqual(username); - expect(token.userId).toEqual(username); - - expect(parseToken(token.serviceToken)).toEqual({ username, password, type }) - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/ping.view' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when the backend is navidrome", () => { - it("should login to nd and get the nd bearer token", async () => { - const navidromeToken = `nd-${uuid()}`; - - (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type: "navidrome" }))); - (axios.post as jest.Mock).mockResolvedValue(ok({ token: navidromeToken })); - - const token = await tokenFor({ - username, - password, - })() - - expect(token.serviceToken).toBeDefined(); - expect(token.nickname).toEqual(username); - expect(token.userId).toEqual(username); - - expect(parseToken(token.serviceToken)).toEqual({ username, password, type: "navidrome", bearer: navidromeToken }) - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/ping.view' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - expect(axios.post).toHaveBeenCalledWith(url.append({ pathname: '/auth/login' }).href(), { - username, - password, - }); - }); - }); - }); - - describe("when the credentials are not valid", () => { - it("should be able to generate a token and then login using it", async () => { - (axios.get as jest.Mock).mockResolvedValue({ - status: 200, - data: error("40", "Wrong username or password"), - }); - - const token = await subsonic.generateToken({ username, password })(); - expect(token).toEqual(E.left(new AuthFailure("Subsonic error:Wrong username or password"))); - }); - }); - }); - - describe("refreshToken", () => { - describe("when the credentials are valid", () => { - describe("when the backend is generic subsonic", () => { - it("should be able to generate a token and then login using it", async () => { - const type = `subsonic-clone-${uuid()}`; - (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type }))); - - const credentials = { username, password, type: "foo", bearer: undefined }; - const originalToken = asToken(credentials) - - const refreshedToken = await pipe( - subsonic.refreshToken(originalToken), - TE.fold(e => { throw e }, T.of) - )(); - - expect(refreshedToken.serviceToken).toBeDefined(); - expect(refreshedToken.nickname).toEqual(credentials.username); - expect(refreshedToken.userId).toEqual(credentials.username); - - expect(parseToken(refreshedToken.serviceToken)).toEqual({ username, password, type }) - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/ping.view' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when the backend is navidrome", () => { - it("should login to nd and get the nd bearer token", async () => { - const navidromeToken = `nd-${uuid()}`; - - (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type: "navidrome" }))); - (axios.post as jest.Mock).mockResolvedValue(ok({ token: navidromeToken })); - - const credentials = { username, password, type: "navidrome", bearer: undefined }; - const originalToken = asToken(credentials) - - const refreshedToken = await pipe( - subsonic.refreshToken(originalToken), - TE.fold(e => { throw e }, T.of) - )(); - - expect(refreshedToken.serviceToken).toBeDefined(); - expect(refreshedToken.nickname).toEqual(username); - expect(refreshedToken.userId).toEqual(username); - - expect(parseToken(refreshedToken.serviceToken)).toEqual({ username, password, type: "navidrome", bearer: navidromeToken }) - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/ping.view' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - expect(axios.post).toHaveBeenCalledWith(url.append({ pathname: '/auth/login' }).href(), { - username, - password, - }); - }); - }); - }); - - describe("when the credentials are not valid", () => { - it("should be able to generate a token and then login using it", async () => { - (axios.get as jest.Mock).mockResolvedValue({ - status: 200, - data: error("40", "Wrong username or password"), - }); - - const credentials = { username, password, type: "foo", bearer: undefined }; - const originalToken = asToken(credentials) - - const token = await subsonic.refreshToken(originalToken)(); - expect(token).toEqual(E.left(new AuthFailure("Subsonic error:Wrong username or password"))); - }); - }); - }); - - describe("login", () => { - describe("when the token is for generic subsonic", () => { - it("should return a subsonic client", async () => { - const client = await subsonic.login(asToken({ username: "foo", password: "bar", type: "subsonic", bearer: undefined })); - expect(client.flavour()).toEqual("subsonic"); - }); - }); - - describe("when the token is for navidrome", () => { - it("should return a navidrome client", async () => { - const client = await subsonic.login(asToken({ username: "foo", password: "bar", type: "navidrome", bearer: undefined })); - expect(client.flavour()).toEqual("navidrome"); - }); - }); - - describe("when the token is for gonic", () => { - it("should return a subsonic client", async () => { - const client = await subsonic.login(asToken({ username: "foo", password: "bar", type: "gonic", bearer: undefined })); - expect(client.flavour()).toEqual("subsonic"); - }); - }); - }); - - describe("bearerToken", () => { - describe("when flavour is generic subsonic", () => { - it("should return undefined", async () => { - const credentials = { username: "foo", password: "bar" }; - const token = { ...credentials, type: "subsonic", bearer: undefined } - const client = await subsonic.login(asToken(token)); - - const bearerToken = await pipe(client.bearerToken(credentials))(); - expect(bearerToken).toStrictEqual(E.right(undefined)); - }); - }); - - describe("when flavour is navidrome", () => { - it("should get a bearerToken from navidrome", async () => { - const credentials = { username: "foo", password: "bar" }; - const token = { ...credentials, type: "navidrome", bearer: undefined } - const client = await subsonic.login(asToken(token)); - - mockPOST.mockImplementationOnce(() => Promise.resolve(ok({ token: 'theBearerToken' }))) - - const bearerToken = await pipe(client.bearerToken(credentials))(); - expect(bearerToken).toStrictEqual(E.right('theBearerToken')); - - expect(axios.post).toHaveBeenCalledWith(url.append({ pathname: '/auth/login' }).href(), credentials) - }); - }); - }); - - }); - - describe("SubsonicMusicLibrary", () => { - const url = new URLBuilder("http://127.0.0.22:4567/some-context-path"); - const username = `user1-${uuid()}`; - const password = `pass1-${uuid()}`; - const salt = "saltysalty"; - - const customPlayers = { - encodingFor: jest.fn() - }; - - const subsonic = new SubsonicMusicLibrary( - new Subsonic(url, customPlayers), - { username, password }, - customPlayers as unknown as CustomPlayers - ); - - const mockRandomstring = jest.fn(); - const mockGET = jest.fn(); - const mockPOST = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - jest.resetAllMocks(); - - randomstring.generate = mockRandomstring; - axios.get = mockGET; - axios.post = mockPOST; - - mockRandomstring.mockReturnValue(salt); - }); - - 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", () => { + + describe("when it all fits on one page", () => { beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(getGenresJson([])))); + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson)) + ); }); - - it("should return empty array", async () => { - const result = await subsonic.genres(); - - expect(result).toEqual([]); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getGenres' }).href(), { - 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 subsonic.genres(); - - expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getGenres' }).href(), { - 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 subsonic.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.append({ pathname: '/rest/getGenres' }).href(), { - 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 subsonic.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.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - 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 subsonic.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.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - 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 subsonic.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.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - 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 subsonic.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.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - 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 subsonic.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.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - 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 subsonic.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.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - 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 subsonic.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.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - 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 subsonic.artist(artist.id!); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: artist.image, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - 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 subsonic.artist(artist.id!); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: artist.image, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - 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 subsonic.artist(artist.id!); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: artist.image, - albums: [], - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - }); - }); - - describe("getting artists", () => { - describe("when there are indexes, but no artists", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve( - ok( - subsonicOK({ - artists: { - index: [ - { - name: "#", - }, - { - name: "A", - }, - { - name: "B", - }, - ], - }, - }) - ) - ) - ); - }); - - it("should return empty", async () => { + + it("should return the single artist", async () => { const artists = await subsonic.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 subsonic.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 subsonic.artists({ _index: 0, _count: 100 }); - - const expectedResults = [{ + + const expectedResults = [ + { id: artist1.id, image: artist1.image, name: artist1.name, - }]; - - expect(artists).toEqual({ - results: expectedResults, - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { + }, + ]; + + expect(artists).toEqual({ + results: expectedResults, + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { 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 subsonic.artists({ _index: 0, _count: 100 }); - - const expectedResults = [artist1, artist2, artist3, artist4].map( - (it) => ({ - id: it.id, - image: it.image, - name: it.name, - }) - ); - - expect(artists).toEqual({ - results: expectedResults, - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - 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 subsonic.artists({ _index: 1, _count: 2 }); - - const expectedResults = [artist2, artist3].map((it) => ({ - id: it.id, - image: it.image, - name: it.name, - })); - - expect(artists).toEqual({ results: expectedResults, total: 4 }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); + } + ); }); }); }); - - describe("getting albums", () => { - describe("filtering", () => { - const album1 = anAlbum({ id: "album1", genre: asGenre("Pop") }); - const album2 = anAlbum({ id: "album2", genre: asGenre("Rock") }); - const album3 = anAlbum({ id: "album3", genre: asGenre("Pop") }); - const album4 = anAlbum({ id: "album4", genre: asGenre("Pop") }); - const album5 = anAlbum({ id: "album5", genre: asGenre("Pop") }); - - const artist = anArtist({ - albums: [album1, album2, album3, album4, album5], + + describe("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))) + ); }); - - 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 return all the artists", async () => { + const artists = await subsonic.artists({ _index: 0, _count: 100 }); + + const expectedResults = [artist1, artist2, artist3, artist4].map( + (it) => ({ + id: it.id, + image: it.image, + name: it.name, + }) + ); + + expect(artists).toEqual({ + results: expectedResults, + total: 4, }); - - 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 subsonic.albums(q); - - expect(result).toEqual({ - results: [album1, album3].map(albumToAlbumSummary), - total: 2, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { + } + ); + }); + }); + + 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 subsonic.artists({ _index: 1, _count: 2 }); + + const expectedResults = [artist2, artist3].map((it) => ({ + id: it.id, + image: it.image, + name: it.name, + })); + + expect(artists).toEqual({ results: expectedResults, total: 4 }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + }); + }); + }); + }); + + describe("getting albums", () => { + describe("filtering", () => { + const album1 = anAlbum({ id: "album1", genre: asGenre("Pop") }); + const album2 = anAlbum({ id: "album2", genre: asGenre("Rock") }); + const album3 = anAlbum({ id: "album3", genre: asGenre("Pop") }); + const album4 = anAlbum({ id: "album4", genre: asGenre("Pop") }); + const album5 = anAlbum({ id: "album5", genre: asGenre("Pop") }); + + const artist = anArtist({ + albums: [album1, album2, album3, album4, album5], + }); + + describe("by genre", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(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 subsonic.albums(q); + + expect(result).toEqual({ + results: [album1, album3].map(albumToAlbumSummary), + total: 2, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, type: "byGenre", @@ -1575,48 +1704,54 @@ describe("SubsonicMusicService", () => { 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], - ]) - ) + }); + + 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 subsonic.albums(q); + + expect(result).toEqual({ + results: [album3, album2, album1].map(albumToAlbumSummary), + total: 3, }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "recentlyAdded", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album3, album2, album1].map(albumToAlbumSummary), - total: 3, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, type: "newest", @@ -1624,48 +1759,54 @@ describe("SubsonicMusicService", () => { 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 - ]) - ) + }); + + 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 subsonic.albums(q); + + expect(result).toEqual({ + results: [album3, album2].map(albumToAlbumSummary), + total: 2, }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "recentlyPlayed", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album3, album2].map(albumToAlbumSummary), - total: 2, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, type: "recent", @@ -1673,39 +1814,45 @@ describe("SubsonicMusicService", () => { 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 - ); + }); + + 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 subsonic.albums(q); + + expect(result).toEqual({ + results: [album2].map(albumToAlbumSummary), + total: 1, }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "mostPlayed" }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album2].map(albumToAlbumSummary), - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, type: "frequent", @@ -1713,39 +1860,45 @@ describe("SubsonicMusicService", () => { 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 - ); + }); + + 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 subsonic.albums(q); + + expect(result).toEqual({ + results: [album2].map(albumToAlbumSummary), + total: 1, }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "starred" }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album2].map(albumToAlbumSummary), - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, type: "highest", @@ -1753,48 +1906,54 @@ describe("SubsonicMusicService", () => { offset: 0, }), headers, - }); - }); + } + ); }); }); - - describe("when the artist has only 1 album", () => { - const artist = anArtist({ - name: "one hit wonder", - albums: [anAlbum({ genre: asGenre("Pop") })], + }); + + 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 subsonic.albums(q); + + expect(result).toEqual({ + results: albums, + total: 1, }); - 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 subsonic.albums(q); - - expect(result).toEqual({ - results: albums, - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, type: "alphabeticalByArtist", @@ -1802,47 +1961,53 @@ describe("SubsonicMusicService", () => { offset: 0, }), headers, - }); - }); + } + ); }); - - describe("when the only artist has no albums", () => { - const artist = anArtist({ - name: "no hit wonder", - albums: [], + }); + + 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 subsonic.albums(q); + + expect(result).toEqual({ + results: albums, + total: 0, }); - 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 subsonic.albums(q); - - expect(result).toEqual({ - results: albums, - total: 0, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, type: "alphabeticalByArtist", @@ -1850,62 +2015,68 @@ describe("SubsonicMusicService", () => { 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 subsonic.albums(q); - - expect(result).toEqual({ - results: albums, - total: 6, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { + }); + + 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 subsonic.albums(q); + + expect(result).toEqual({ + results: albums, + total: 6, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, type: "alphabeticalByArtist", @@ -1913,12 +2084,88 @@ describe("SubsonicMusicService", () => { offset: 0, }), headers, - }); - }); + } + ); }); - - describe("querying for a page of them", () => { - it("should return the page with the corrent paging information", async () => { + }); + + 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 subsonic.albums(q); + + expect(result).toEqual({ + results: [artist1.albums[2], artist2.albums[0]], + total: 6, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + 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))) @@ -1927,966 +2174,955 @@ describe("SubsonicMusicService", () => { 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]!], + [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: 2, + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [album1, album2, album3, album5], + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + 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 subsonic.albums(q); - + expect(result).toEqual({ - results: [artist1.albums[2], artist2.albums[0]], - total: 6, + results: [album1, album2], + total: 4, }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 2, - }), - headers, + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + 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 subsonic.albums(q); + + expect(result).toEqual({ + results: [album3, album5], + total: 4, }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + 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], - ]) - ) + + 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, + ]) ) - ); - }); - - 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 subsonic.albums(q); - - expect(result).toEqual({ - results: [album1, album2, album3, album5], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getAlbumList2' }).href(), - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } + ) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + [artist1, album3], + [artist1, album4], + [artist2, album5], + ]) + ) + ) ); - }); }); - - 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 return the page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [album1, album2, album3, album4, album5], + total: 5, }); - - it("should filter out the pre-fetched albums", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 2, - type: "alphabeticalByArtist", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album1, album2], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getAlbumList2' }).href(), - { - 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 subsonic.albums(q); - - expect(result).toEqual({ - results: [album3, album5], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getAlbumList2' }).href(), - { - 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, - ]) - ) + + 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], - ]) - ) + ) + .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 subsonic.albums(q); - - expect(result).toEqual({ - results: [album1, album2, album3, album4, album5], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getAlbumList2' }).href(), - { - 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 subsonic.albums(q); + + expect(result).toEqual({ + results: [album1, album2], + total: 5, }); - - it("should filter out the pre-fetched albums", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 2, - type: "alphabeticalByArtist", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album1, album2], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getAlbumList2' }).href(), - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + 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, - ]) - ) + }); + + 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], - ]) - ) + ) + .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 subsonic.albums(q); + + expect(result).toEqual({ + results: [album3, album4, album5], + total: 5, }); - - 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 subsonic.albums(q); - - expect(result).toEqual({ - results: [album3, album4, album5], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getAlbumList2' }).href(), - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); }); }); }); }); - - describe("getting an album", () => { + }); + + describe("getting an album", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + 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(() => { - customPlayers.encodingFor.mockReturnValue(O.none); + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); }); - - 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 subsonic.album(album.id); - - expect(result).toEqual(album); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { + + it("should return the album", async () => { + const result = await subsonic.album(album.id); + + expect(result).toEqual(album); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, id: album.id, }), headers, - }); - }); + } + ); }); }); - - describe("getting tracks", () => { - describe("for an album", () => { - describe("when there are no custom players", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - 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 subsonic.tracks(album.id); - - expect(result).toEqual([track1, track2, track3, track4]); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - 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 subsonic.tracks(album.id); - - expect(result).toEqual([track]); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - 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 subsonic.tracks(album.id); - - expect(result).toEqual([]); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); + }); + + describe("getting tracks", () => { + describe("for an album", () => { + describe("when there are no custom players", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); }); - - describe("when a custom player is configured for the mime type", () => { + + 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 album = anAlbum({ + id: "album1", + name: "Burnin", + genre: hipHop, + }); + const artist = anArtist({ id: "artist1", name: "Bob Marley", albums: [album], }); - - const alac = aTrack({ + + const track1 = aTrack({ artist: artistToArtistSummary(artist), album: albumToAlbumSummary(album), - encoding: { - player: "bonob", - mimeType: "audio/alac" - }, genre: hipHop, rating: { love: true, stars: 3, }, }); - const m4a = aTrack({ + const track2 = aTrack({ artist: artistToArtistSummary(artist), album: albumToAlbumSummary(album), - encoding: { - player: "bonob", - mimeType: "audio/m4a" - }, genre: hipHop, rating: { love: false, stars: 0, }, }); - const mp3 = aTrack({ + const track3 = aTrack({ artist: artistToArtistSummary(artist), album: albumToAlbumSummary(album), - encoding: { - player: "bonob", - mimeType: "audio/mp3" - }, 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(() => { - customPlayers.encodingFor - .mockReturnValueOnce(O.of({ player: "bonob+audio/alac", mimeType: "audio/flac" })) - .mockReturnValueOnce(O.of({ player: "bonob+audio/m4a", mimeType: "audio/opus" })) - .mockReturnValueOnce(O.none) - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, [alac, m4a, mp3]))) - ); + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) + ); }); - - it("should return the album with custom players applied", async () => { + + it("should return the album", async () => { const result = await subsonic.tracks(album.id); - - expect(result).toEqual([ + + expect(result).toEqual([track1, track2, track3, track4]); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), { - ...alac, - encoding: { - player: "bonob+audio/alac", - mimeType: "audio/flac" - } - }, - { - ...m4a, - encoding: { - player: "bonob+audio/m4a", - mimeType: "audio/opus" - } - }, - { - ...mp3, - encoding: { - player: "bonob", - mimeType: "audio/mp3" - } - }, - ]); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - - expect(customPlayers.encodingFor).toHaveBeenCalledTimes(3); - expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(1, { mimeType: "audio/alac" }) - expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(2, { mimeType: "audio/m4a" }) - expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(3, { mimeType: "audio/mp3" }) + 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 subsonic.tracks(album.id); + + expect(result).toEqual([track]); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), + { + 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 subsonic.tracks(album.id); + + expect(result).toEqual([]); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + } + ); + }); + }); }); - - describe("a single track", () => { - const pop = asGenre("Pop"); - - const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - + + describe("when a custom player is configured for the mime type", () => { + 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], }); - - describe("when there are no custom players", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); + + const alac = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + encoding: { + player: "bonob", + mimeType: "audio/alac", + }, + genre: hipHop, + rating: { + love: true, + stars: 3, + }, + }); + const m4a = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + encoding: { + player: "bonob", + mimeType: "audio/m4a", + }, + genre: hipHop, + rating: { + love: false, + stars: 0, + }, + }); + const mp3 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + encoding: { + player: "bonob", + mimeType: "audio/mp3", + }, + genre: tripHop, + rating: { + love: true, + stars: 5, + }, + }); + + beforeEach(() => { + customPlayers.encodingFor + .mockReturnValueOnce( + O.of({ player: "bonob+audio/alac", mimeType: "audio/flac" }) + ) + .mockReturnValueOnce( + O.of({ player: "bonob+audio/m4a", mimeType: "audio/opus" }) + ) + .mockReturnValueOnce(O.none); + + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, [alac, m4a, mp3]))) + ); + }); + + it("should return the album with custom players applied", async () => { + const result = await subsonic.tracks(album.id); + + expect(result).toEqual([ + { + ...alac, + encoding: { + player: "bonob+audio/alac", + mimeType: "audio/flac", + }, + }, + { + ...m4a, + encoding: { + player: "bonob+audio/m4a", + mimeType: "audio/opus", + }, + }, + { + ...mp3, + encoding: { + player: "bonob", + mimeType: "audio/mp3", + }, + }, + ]); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + } + ); + + expect(customPlayers.encodingFor).toHaveBeenCalledTimes(3); + expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(1, { + mimeType: "audio/alac", }); - - 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 subsonic.track(track.id); - - expect(result).toEqual({ - ...track, - rating: { love: true, stars: 4 }, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSong' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: track.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); + expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(2, { + mimeType: "audio/m4a", }); - - 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 subsonic.track(track.id); - - expect(result).toEqual({ - ...track, - rating: { love: false, stars: 0 }, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSong' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: track.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); + expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(3, { + mimeType: "audio/mp3", }); }); }); }); - - describe("streaming a track", () => { - - const trackId = uuid(); - const genre = aGenre("foo"); - - const album = anAlbum({ genre }); + + describe("a single track", () => { + const pop = asGenre("Pop"); + + const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist = anArtist({ - albums: [album] + id: "artist1", + name: "Bob Marley", + albums: [album], }); - const track = aTrack({ - id: trackId, - album: albumToAlbumSummary(album), - artist: artistToArtistSummary(artist), - genre, - }); - - describe("when there are no custom players registered", () => { + + describe("when there are no custom players", () => { beforeEach(() => { customPlayers.encodingFor.mockReturnValue(O.none); }); - - describe("content-range, accept-ranges or content-length", () => { - 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 subsonic.stream({ trackId, range: undefined }); - - expect(result.headers).toEqual({ + + 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 subsonic.track(track.id); + + expect(result).toEqual({ + ...track, + rating: { love: true, stars: 4 }, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getSong" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: track.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), + { + 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 subsonic.track(track.id); + + expect(result).toEqual({ + ...track, + rating: { love: false, stars: 0 }, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getSong" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: track.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), + { + 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("when there are no custom players registered", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("content-range, accept-ranges or content-length", () => { + 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 subsonic.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 subsonic.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 () => { + }); + + 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": 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 subsonic.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 subsonic.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.append({ pathname: '/rest/stream' }).href(), { + "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 subsonic.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.append({ pathname: "/rest/stream" }).href(), + { params: asURLSearchParams({ ...authParams, id: trackId, @@ -2895,77 +3131,23 @@ describe("SubsonicMusicService", () => { "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( - subsonic.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( - subsonic.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"; + + describe("navidrome returns something other than a 200", () => { + it("should fail", async () => { + const trackId = "track123"; + const streamResponse = { - status: 200, + status: 400, headers: { - "content-type": "audio/flac", - "content-length": "66", - "content-range": "100-200", - "accept-ranges": "none", - "some-other-header": "some-value", + "content-type": "text/html", + "content-length": "33", }, - data: stream, }; - + mockGET .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) @@ -2974,18 +3156,76 @@ describe("SubsonicMusicService", () => { Promise.resolve(ok(getAlbumJson(artist, album, []))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await subsonic.stream({ trackId, range }); - - expect(result.headers).toEqual({ + + return expect( + subsonic.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( + subsonic.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", - }); - expect(result.stream).toEqual(stream); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), { + "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 subsonic.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.append({ pathname: "/rest/stream" }).href(), + { params: asURLSearchParams({ ...authParams, id: trackId, @@ -2995,48 +3235,53 @@ describe("SubsonicMusicService", () => { Range: range, }, responseType: "stream", - }); - }); + } + ); }); }); }); - - describe("when there are custom players registered", () => { - const customEncoding = { - player: `bonob-${uuid()}`, - mimeType: "transocodedMimeType" - }; - const trackWithCustomPlayer: Track = { - ...track, - encoding: customEncoding - }; - - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.of(customEncoding)); - }); - - describe("when no range specified", () => { - it("should user the custom client specified by the stream client", async () => { - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - }, - data: Buffer.from("the track", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(trackWithCustomPlayer))) + }); + + describe("when there are custom players registered", () => { + const customEncoding = { + player: `bonob-${uuid()}`, + mimeType: "transocodedMimeType", + }; + const trackWithCustomPlayer: Track = { + ...track, + encoding: customEncoding, + }; + + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.of(customEncoding)); + }); + + describe("when no range specified", () => { + it("should user the custom client specified by the stream client", async () => { + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + }, + data: Buffer.from("the track", "ascii"), + }; + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(trackWithCustomPlayer))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok(getAlbumJson(artist, album, [trackWithCustomPlayer])) ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, [trackWithCustomPlayer]))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - await subsonic.stream({ trackId, range: undefined }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), { + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + await subsonic.stream({ trackId, range: undefined }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/stream" }).href(), + { params: asURLSearchParams({ ...authParams, id: trackId, @@ -3046,34 +3291,39 @@ describe("SubsonicMusicService", () => { "User-Agent": "bonob", }, responseType: "stream", - }); - }); + } + ); }); - - describe("when range specified", () => { - it("should user the custom client specified by the stream client", async () => { - const range = "1000-2000"; - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - }, - data: Buffer.from("the track", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(trackWithCustomPlayer))) + }); + + describe("when range specified", () => { + it("should user the custom client specified by the stream client", async () => { + const range = "1000-2000"; + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + }, + data: Buffer.from("the track", "ascii"), + }; + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(trackWithCustomPlayer))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok(getAlbumJson(artist, album, [trackWithCustomPlayer])) ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, [trackWithCustomPlayer]))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - await subsonic.stream({ trackId, range }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), { + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + await subsonic.stream({ trackId, range }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/stream" }).href(), + { params: asURLSearchParams({ ...authParams, id: trackId, @@ -3084,71 +3334,81 @@ describe("SubsonicMusicService", () => { 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 subsonic.coverArt(coverArtURN); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getCoverArt' }).href(), { + }); + + 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 subsonic.coverArt(coverArtURN); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getCoverArt" }).href(), + { 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 subsonic.coverArt(coverArtURN, size); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getCoverArt' }).href(), { + }); + + 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 subsonic.coverArt(coverArtURN, size); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getCoverArt" }).href(), + { params: asURLSearchParams({ ...authParams, id: coverArtId, @@ -3156,488 +3416,538 @@ describe("SubsonicMusicService", () => { }), 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 subsonic.coverArt( + { system: "external", resource: "http://localhost:404" }, + size + ); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe("fetching cover art", () => { + describe("when urn.resource is not subsonic", () => { + it("should be undefined", async () => { + const covertArtURN = { + system: "notSubsonic", + resource: `art:${uuid()}`, + }; + + const result = await subsonic.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 subsonic.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("when an unexpected error occurs", () => { + + describe("and an error occurs fetching the uri", () => { it("should return undefined", async () => { - const size = 1879; - - mockGET - .mockImplementationOnce(() => Promise.reject("BOOOM")); - - const result = await subsonic.coverArt({ system: "external", resource: "http://localhost:404" }, size); - + const coverArtId = uuid(); + const covertArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; + + mockGET.mockImplementationOnce(() => Promise.reject("BOOOM")); + + const result = await subsonic.coverArt(covertArtURN); + expect(result).toBeUndefined(); }); }); }); - - describe("fetching cover art", () => { - describe("when urn.resource is not subsonic", () => { - it("should be undefined", async () => { - const covertArtURN = { system: "notSubsonic", resource: `art:${uuid()}` }; - - const result = await subsonic.coverArt(covertArtURN, 190); - - 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 subsonic.coverArt(covertArtURN, size); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getCoverArt" }).href(), + { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + size, + }), + headers, + responseType: "arraybuffer", + } + ); }); - - 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"), + + 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(streamResponse)); - - const result = await subsonic.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 subsonic.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)); - + + mockGET.mockImplementationOnce(() => Promise.reject("BOOOM")); + const result = await subsonic.coverArt(covertArtURN, size); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getCoverArt' }).href(), - { - 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 subsonic.coverArt(covertArtURN, size); - - expect(result).toBeUndefined(); - }); + + expect(result).toBeUndefined(); }); }); }); }); - - describe("rate", () => { - const trackId = uuid(); - - const artist = anArtist(); - const album = anAlbum({ id: "album1", name: "Burnin", genre: POP }); - - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("rate", () => { + const trackId = uuid(); + + const artist = anArtist(); + const album = anAlbum({ id: "album1", name: "Burnin", genre: POP }); + + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + 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 subsonic.rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/star" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + } + ); + }); }); - - 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 subsonic.rate(trackId, { love: true, stars: 0 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/star' }).href(), { + + 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 subsonic.rate(trackId, { + love: false, + stars: 0, + }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/unstar" }).href(), + { 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 subsonic.rate(trackId, { love: false, stars: 0 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/unstar' }).href(), { - 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 subsonic.rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledTimes(2); }); - - 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 subsonic.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 }, }); - }); - - 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 subsonic.rate(trackId, { love: false, stars: 3 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/setRating' }).href(), { + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await subsonic.rate(trackId, { + love: false, + stars: 3, + }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/setRating" }).href(), + { 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 subsonic.rate(trackId, { love: true, stars: 3 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledTimes(2); + }); + + 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 subsonic.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 subsonic.rate(trackId, { love: false, stars: 5 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/unstar' }).href(), { + }); + + 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 subsonic.rate(trackId, { + love: false, + stars: 5, + }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/unstar" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, id: trackId, }), headers, - }); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/setRating' }).href(), { + } + ); + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/setRating" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, id: trackId, rating: 5, }), headers, - }); - }); + } + ); }); - - describe("invalid star values", () => { - describe("stars of -1", () => { - it("should return false", async () => { - const result = await subsonic.rate(trackId, { love: true, stars: -1 }); - expect(result).toEqual(false); - }); - }); - - describe("stars of 6", () => { - it("should return false", async () => { - const result = await subsonic.rate(trackId, { love: true, stars: -1 }); - expect(result).toEqual(false); - }); - }); - }); - - describe("when fails", () => { + }); + + describe("invalid star values", () => { + describe("stars of -1", () => { it("should return false", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(FAILURE))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await subsonic.rate(trackId, { love: true, stars: 0 }); - + const result = await subsonic.rate(trackId, { + love: true, + stars: -1, + }); + expect(result).toEqual(false); + }); + }); + + describe("stars of 6", () => { + it("should return false", async () => { + const result = await subsonic.rate(trackId, { + love: true, + stars: -1, + }); expect(result).toEqual(false); }); }); }); - }); - - describe("scrobble", () => { - describe("when succeeds", () => { - it("should return true", async () => { - const id = uuid(); - + + describe("when fails", () => { + it("should return false", async () => { mockGET + .mockImplementationOnce(() => Promise.resolve(ok(FAILURE))) .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await subsonic.scrobble(id); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/scrobble' }).href(), { + + const result = await subsonic.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 subsonic.scrobble(id); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/scrobble" }).href(), + { 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 subsonic.scrobble(id); - - expect(result).toEqual(false); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/scrobble' }).href(), { + }); + + describe("when fails", () => { + it("should return false", async () => { + const id = uuid(); + + mockGET.mockImplementationOnce(() => + Promise.resolve({ + status: 500, + data: {}, + }) + ); + + const result = await subsonic.scrobble(id); + + expect(result).toEqual(false); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/scrobble" }).href(), + { 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 subsonic.nowPlaying(id); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/scrobble' }).href(), { + }); + + describe("nowPlaying", () => { + describe("when succeeds", () => { + it("should return true", async () => { + const id = uuid(); + + mockGET.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await subsonic.nowPlaying(id); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/scrobble" }).href(), + { 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 subsonic.nowPlaying(id); - - expect(result).toEqual(false); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/scrobble' }).href(), { + }); + + describe("when fails", () => { + it("should return false", async () => { + const id = uuid(); + + mockGET.mockImplementationOnce(() => + Promise.resolve({ + status: 500, + data: {}, + }) + ); + + const result = await subsonic.nowPlaying(id); + + expect(result).toEqual(false); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/scrobble" }).href(), + { 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 subsonic.searchArtists("foo"); - - expect(result).toEqual([artistToArtistSummary(artist1)]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { + }); + + 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 subsonic.searchArtists("foo"); + + expect(result).toEqual([artistToArtistSummary(artist1)]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, artistCount: 20, @@ -3646,30 +3956,32 @@ describe("SubsonicMusicService", () => { 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 subsonic.searchArtists("foo"); - - expect(result).toEqual([ - artistToArtistSummary(artist1), - artistToArtistSummary(artist2), - ]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { + }); + + 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 subsonic.searchArtists("foo"); + + expect(result).toEqual([ + artistToArtistSummary(artist1), + artistToArtistSummary(artist2), + ]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, artistCount: 20, @@ -3678,22 +3990,24 @@ describe("SubsonicMusicService", () => { query: "foo", }), headers, - }); - }); + } + ); }); - - describe("when there are no search results", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) - ); - - const result = await subsonic.searchArtists("foo"); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) + ); + + const result = await subsonic.searchArtists("foo"); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, artistCount: 20, @@ -3702,32 +4016,34 @@ describe("SubsonicMusicService", () => { 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 subsonic.searchAlbums("foo"); - - expect(result).toEqual([albumToAlbumSummary(album)]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { + }); + + 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 subsonic.searchAlbums("foo"); + + expect(result).toEqual([albumToAlbumSummary(album)]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, artistCount: 0, @@ -3736,884 +4052,936 @@ describe("SubsonicMusicService", () => { 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 subsonic.searchAlbums("moo"); - - expect(result).toEqual([ - albumToAlbumSummary(album1), - albumToAlbumSummary(album2), - ]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - 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 subsonic.searchAlbums("foo"); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 20, - songCount: 0, - query: "foo", - }), - headers, - }); - }); + } + ); }); }); - - describe("searchSongs", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - 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 subsonic.searchTracks("foo"); - - expect(result).toEqual([track]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - 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 album1 = anAlbum({ + name: "album1", + genre: { id: b64Encode("pop"), name: "pop" }, }); - }); - - 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 subsonic.searchTracks("moo"); - - expect(result).toEqual([track1, track2]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 0, - songCount: 20, - query: "moo", - }), - headers, - }); + const artist1 = anArtist({ name: "artist1", albums: [album1] }); + + const album2 = anAlbum({ + name: "album2", + genre: { id: b64Encode("pop"), name: "pop" }, }); - }); - - describe("when there are no search results", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ tracks: [] }))) - ); - - const result = await subsonic.searchTracks("foo"); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 0, - songCount: 20, - query: "foo", - }), - headers, - }); - }); - }); - }); - - describe("playlists", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - 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 subsonic.playlists(); - - expect(result).toEqual([playlist]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylists' }).href(), { - 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 subsonic.playlists(); - - expect(result).toEqual(playlists); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when there are no playlists", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson([]))) - ); - - const result = await subsonic.playlists(); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylists' }).href(), { - 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( - subsonic.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 subsonic.playlist(id); - - expect(result).toEqual({ - id, - name, - entries: [ - { ...track1, number: 1 }, - { ...track2, number: 2 }, + const artist2 = anArtist({ name: "artist2", albums: [album2] }); + + mockGET.mockImplementationOnce(() => + Promise.resolve( + ok( + getSearchResult3Json({ + albums: [ + { artist: artist1, album: album1 }, + { artist: artist2, album: album2 }, ], - }); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylist' }).href(), { + }) + ) + ) + ); + + const result = await subsonic.searchAlbums("moo"); + + expect(result).toEqual([ + albumToAlbumSummary(album1), + albumToAlbumSummary(album2), + ]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + 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 subsonic.searchAlbums("foo"); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "foo", + }), + headers, + } + ); + }); + }); + }); + + describe("searchSongs", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + 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 subsonic.searchTracks("foo"); + + expect(result).toEqual([track]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + 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 subsonic.searchTracks("moo"); + + expect(result).toEqual([track1, track2]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + 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 subsonic.searchTracks("foo"); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 0, + songCount: 20, + query: "foo", + }), + headers, + } + ); + }); + }); + }); + + describe("playlists", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + 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 subsonic.playlists(); + + expect(result).toEqual([playlist]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getPlaylists" }).href(), + { + 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 subsonic.playlists(); + + expect(result).toEqual(playlists); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getPlaylists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + }); + }); + + describe("when there are no playlists", () => { + it("should return []", async () => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson([]))) + ); + + const result = await subsonic.playlists(); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getPlaylists" }).href(), + { + 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(subsonic.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 subsonic.playlist(id); + + expect(result).toEqual({ + id, + name, + entries: [ + { ...track1, number: 1 }, + { ...track2, number: 2 }, + ], + }); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getPlaylist" }).href(), + { 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 subsonic.playlist(playlist.id); - - expect(result).toEqual(playlist); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylist' }).href(), { + }); + + 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 subsonic.playlist(playlist.id); + + expect(result).toEqual(playlist); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getPlaylist" }).href(), + { 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 subsonic.createPlaylist(name); - - expect(result).toEqual({ id, name }); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/createPlaylist' }).href(), { + }); + + 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 subsonic.createPlaylist(name); + + expect(result).toEqual({ id, name }); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/createPlaylist" }).href(), + { 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 subsonic.deletePlaylist(id); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/deletePlaylist' }).href(), { + }); + + 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 subsonic.deletePlaylist(id); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/deletePlaylist" }).href(), + { 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 subsonic.addToPlaylist(playlistId, trackId); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/updatePlaylist' }).href(), { + }); + + 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 subsonic.addToPlaylist(playlistId, trackId); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/updatePlaylist" }).href(), + { 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 subsonic.removeFromPlaylist(playlistId, indicies); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/updatePlaylist' }).href(), { + }); + + 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 subsonic.removeFromPlaylist( + playlistId, + indicies + ); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/updatePlaylist" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, playlistId, songIndexToRemove: indicies, }), headers, - }); - }); - }); - }); - }); - - describe("similarSongs", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - 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 subsonic.similarSongs(id); - - expect(result).toEqual([track1]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSimilarSongs2' }).href(), { - 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 subsonic.similarSongs(id); - - expect(result).toEqual([track1, track2, track3]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSimilarSongs2' }).href(), { - 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 subsonic.similarSongs(id); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSimilarSongs2' }).href(), { - 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( - subsonic.similarSongs(id) - ).rejects.toEqual("Subsonic error:data not found"); - }); - }); - }); - - describe("topSongs", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - 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 subsonic.topSongs(artistId); - - expect(result).toEqual([track1]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getTopSongs' }).href(), { - 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 subsonic.topSongs(artistId); - - expect(result).toEqual([track1, track2, track3]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getTopSongs' }).href(), { - 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 subsonic.topSongs(artistId); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getTopSongs' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, - }); - }); - }); - }); - - describe("radioStations", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - describe("when there some radio stations", () => { - const station1 = aRadioStation(); - const station2 = aRadioStation(); - const station3 = aRadioStation(); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getRadioStationsJson([ - station1, - station2, - station3, - ]))) + } ); }); - - describe("asking for all of them", () => { - it("should return them all", async () => { - const result = await subsonic.radioStations(); - - expect(result).toEqual([station1, station2, station3]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json" - }), - headers, - }); - }); - }); - - describe("asking for one of them", () => { - it("should return it", async () => { - const result = await subsonic.radioStation(station2.id); - - expect(result).toEqual(station2); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json" - }), - headers, - }); - }); - }); }); - - describe("when there are no radio stations", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getRadioStationsJson([]))) - ); - - const result = await subsonic.radioStations(); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), { + }); + }); + + describe("similarSongs", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + 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 subsonic.similarSongs(id); + + expect(result).toEqual([track1]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getSimilarSongs2" }).href(), + { params: asURLSearchParams({ ...authParams, - f: "json" + 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 subsonic.similarSongs(id); + + expect(result).toEqual([track1, track2, track3]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getSimilarSongs2" }).href(), + { + 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 subsonic.similarSongs(id); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getSimilarSongs2" }).href(), + { + 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(subsonic.similarSongs(id)).rejects.toEqual( + "Subsonic error:data not found" + ); + }); + }); + }); + + describe("topSongs", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + 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 subsonic.topSongs(artistId); + + expect(result).toEqual([track1]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getTopSongs" }).href(), + { + 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 subsonic.topSongs(artistId); + + expect(result).toEqual([track1, track2, track3]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getTopSongs" }).href(), + { + 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 subsonic.topSongs(artistId); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getTopSongs" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + artist: artistName, + count: 50, + }), + headers, + } + ); + }); + }); + }); + + describe("radioStations", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("when there some radio stations", () => { + const station1 = aRadioStation(); + const station2 = aRadioStation(); + const station3 = aRadioStation(); + + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve( + ok(getRadioStationsJson([station1, station2, station3])) + ) + ); + }); + + describe("asking for all of them", () => { + it("should return them all", async () => { + const result = await subsonic.radioStations(); + + expect(result).toEqual([station1, station2, station3]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getInternetRadioStations" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + }), + headers, + } + ); + }); + }); + + describe("asking for one of them", () => { + it("should return it", async () => { + const result = await subsonic.radioStation(station2.id); + + expect(result).toEqual(station2); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getInternetRadioStations" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + }), + headers, + } + ); }); }); }); - + + describe("when there are no radio stations", () => { + it("should return []", async () => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getRadioStationsJson([]))) + ); + + const result = await subsonic.radioStations(); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getInternetRadioStations" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + }), + headers, + } + ); + }); + }); }); - \ No newline at end of file +});