diff --git a/README.md b/README.md index 18d9eec..bfebc95 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Currently only a single integration allowing Navidrome to be registered with son - Transcoding performed by Navidrome with specific player for bonob/sonos, customisable by mimeType - Ability to search by Album, Artist, Track - Ability to play a playlist +- Ability to add/remove playlists +- Ability to add/remove tracks from a playlist ## Running @@ -113,4 +115,3 @@ BONOB_NAVIDROME_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for cust ## TODO - Artist Radio -- Add tracks to playlists diff --git a/src/music_service.ts b/src/music_service.ts index 9cecc13..683b88a 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -174,4 +174,8 @@ export interface MusicLibrary { searchTracks(query: string): Promise; playlists(): Promise; playlist(id: string): Promise; + createPlaylist(name: string): Promise + deletePlaylist(id: string): Promise + addToPlaylist(playlistId: string, trackId: string): Promise + removeFromPlaylist(playlistId: string, indicies: number[]): Promise } diff --git a/src/navidrome.ts b/src/navidrome.ts index 5fe8527..ec1249c 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -21,7 +21,7 @@ import { } from "./music_service"; import X2JS from "x2js"; import sharp from "sharp"; -import { pick } from "underscore"; +import _, { pick } from "underscore"; import axios, { AxiosRequestConfig } from "axios"; import { Encryption } from "./encryption"; @@ -278,6 +278,16 @@ export function appendMimeTypeToClientFor(mimeTypes: string[]) { mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob"; } +export const asURLSearchParams = (q: any) => { + const urlSearchParams = new URLSearchParams(); + Object.keys(q).forEach((k) => { + _.flatten([q[k]]).forEach((v) => { + urlSearchParams.append(k, `${v}`); + }); + }); + return urlSearchParams; +}; + export class Navidrome implements MusicService { url: string; encryption: Encryption; @@ -301,13 +311,13 @@ export class Navidrome implements MusicService { ) => axios .get(`${this.url}${path}`, { - params: { + params: asURLSearchParams({ u: username, v: "1.16.1", c: DEFAULT_CLIENT_APPLICATION, ...t_and_s(password), ...q, - }, + }), headers: { "User-Agent": USER_AGENT, }, @@ -345,7 +355,7 @@ export class Navidrome implements MusicService { .then((json) => json["subsonic-response"]) .then((json) => { if (isError(json)) throw json.error._message; - else return (json as unknown) as T; + else return json as unknown as T; }); generateToken = async (credentials: Credentials) => @@ -437,15 +447,10 @@ export class Navidrome implements MusicService { })); getCoverArt = (credentials: Credentials, id: string, size?: number) => - this.get( - credentials, - "/rest/getCoverArt", - { id, size }, - { - headers: { "User-Agent": "bonob" }, - responseType: "arraybuffer", - } - ); + this.get(credentials, "/rest/getCoverArt", size ? { id, size } : { id }, { + headers: { "User-Agent": "bonob" }, + responseType: "arraybuffer", + }); getTrack = (credentials: Credentials, id: string) => this.getJSON(credentials, "/rest/getSong", { @@ -674,7 +679,6 @@ export class Navidrome implements MusicService { name: entry._album, year: entry._year, genre: maybeAsGenre(entry._genre), - artistName: entry._artist, artistId: entry._artistId, }, @@ -683,8 +687,35 @@ export class Navidrome implements MusicService { name: entry._artist, }, })), - } + }; }), + createPlaylist: async (name: string) => + navidrome + .getJSON(credentials, "/rest/createPlaylist", { + name, + }) + .then((it) => it.playlist) + .then((it) => ({ id: it._id, name: it._name })), + deletePlaylist: async (id: string) => + navidrome + .getJSON(credentials, "/rest/deletePlaylist", { + id, + }) + .then((_) => true), + addToPlaylist: async (playlistId: string, trackId: string) => + navidrome + .getJSON(credentials, "/rest/updatePlaylist", { + playlistId, + songIdToAdd: trackId, + }) + .then((_) => true), + removeFromPlaylist: async (playlistId: string, indicies: number[]) => + navidrome + .getJSON(credentials, "/rest/updatePlaylist", { + playlistId, + songIndexToRemove: indicies, + }) + .then((_) => true), }; return Promise.resolve(musicLibrary); diff --git a/src/smapi.ts b/src/smapi.ts index 0e527e5..f5abc66 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -205,10 +205,15 @@ const genre = (genre: Genre) => ({ }); const playlist = (playlist: PlaylistSummary) => ({ - itemType: "album", + itemType: "playlist", id: `playlist:${playlist.id}`, title: playlist.name, canPlay: true, + attributes: { + readOnly: false, + userContent: false, + renameable: false, + }, }); export const defaultAlbumArtURI = ( @@ -279,7 +284,6 @@ export const artist = ( const auth = async ( musicService: MusicService, accessTokens: AccessTokens, - id: string, headers?: SoapyHeaders ) => { if (!headers?.credentials) { @@ -292,15 +296,13 @@ const auth = async ( } const authToken = headers.credentials.loginToken.token; const accessToken = accessTokens.mint(authToken); - const [type, typeId] = id.split(":"); + return musicService .login(authToken) .then((musicLibrary) => ({ musicLibrary, authToken, accessToken, - type, - typeId, })) .catch((_) => { throw { @@ -312,6 +314,15 @@ const auth = async ( }); }; +function splitId(id: string) { + const [type, typeId] = id.split(":"); + return (t: T) => ({ + ...t, + type, + typeId: typeId!, + }); +} + type SoapyHeaders = { credentials?: Credentials; }; @@ -337,9 +348,10 @@ function bindSmapiSoapServiceToExpress( sonosSoap.getDeviceAuthToken({ linkCode }), getLastUpdate: () => ({ getLastUpdateResult: { + autoRefreshEnabled: true, favorites: clock.now().unix(), catalog: clock.now().unix(), - pollInterval: 120, + pollInterval: 60, }, }), getMediaURI: async ( @@ -347,8 +359,9 @@ function bindSmapiSoapServiceToExpress( _, headers?: SoapyHeaders ) => - auth(musicService, accessTokens, id, headers).then( - ({ accessToken, type, typeId }) => ({ + auth(musicService, accessTokens, headers) + .then(splitId(id)) + .then(({ accessToken, type, typeId }) => ({ getMediaURIResult: `${webAddress}/stream/${type}/${typeId}`, httpHeaders: [ { @@ -356,26 +369,27 @@ function bindSmapiSoapServiceToExpress( value: accessToken, }, ], - }) - ), + })), getMediaMetadata: async ( { id }: { id: string }, _, headers?: SoapyHeaders ) => - auth(musicService, accessTokens, id, headers).then( - async ({ musicLibrary, accessToken, typeId }) => + auth(musicService, accessTokens, headers) + .then(splitId(id)) + .then(async ({ musicLibrary, accessToken, typeId }) => musicLibrary.track(typeId!).then((it) => ({ getMediaMetadataResult: track(webAddress, accessToken, it), })) - ), + ), search: async ( { id, term }: { id: string; term: string }, _, headers?: SoapyHeaders ) => - auth(musicService, accessTokens, id, headers).then( - async ({ musicLibrary, accessToken }) => { + auth(musicService, accessTokens, headers) + .then(splitId(id)) + .then(async ({ musicLibrary, accessToken }) => { switch (id) { case "albums": return musicLibrary.searchAlbums(term).then((it) => @@ -407,8 +421,7 @@ function bindSmapiSoapServiceToExpress( default: throw `Unsupported search by:${id}`; } - } - ), + }), getExtendedMetadata: async ( { id, @@ -419,12 +432,13 @@ function bindSmapiSoapServiceToExpress( _, headers?: SoapyHeaders ) => - auth(musicService, accessTokens, id, headers).then( - async ({ musicLibrary, accessToken, type, typeId }) => { + auth(musicService, accessTokens, headers) + .then(splitId(id)) + .then(async ({ musicLibrary, accessToken, type, typeId }) => { const paging = { _index: index, _count: count }; switch (type) { case "artist": - return musicLibrary.artist(typeId!).then((artist) => { + return musicLibrary.artist(typeId).then((artist) => { const [page, total] = slice2(paging)( artist.albums ); @@ -448,11 +462,35 @@ function bindSmapiSoapServiceToExpress( }, }; }); + case "track": + return musicLibrary.track(typeId).then((it) => ({ + getExtendedMetadataResult: { + mediaMetadata: { + id: `track:${it.id}`, + itemType: "track", + title: it.name, + mimeType: it.mimeType, + trackMetadata: { + artistId: it.artist.id, + artist: it.artist.name, + albumId: it.album.id, + album: it.album.name, + genre: it.genre?.name, + genreId: it.genre?.id, + duration: it.duration, + albumArtURI: defaultAlbumArtURI( + webAddress, + accessToken, + it.album + ), + }, + }, + }, + })); default: - throw `Unsupported id:${id}`; + throw `Unsupported getExtendedMetadata id=${id}`; } - } - ), + }), getMetadata: async ( { id, @@ -463,8 +501,9 @@ function bindSmapiSoapServiceToExpress( _, headers?: SoapyHeaders ) => - auth(musicService, accessTokens, id, headers).then( - ({ musicLibrary, accessToken, type, typeId }) => { + auth(musicService, accessTokens, headers) + .then(splitId(id)) + .then(({ musicLibrary, accessToken, type, typeId }) => { const paging = { _index: index, _count: count }; logger.debug( `Fetching metadata type=${type}, typeId=${typeId}` @@ -496,9 +535,14 @@ function bindSmapiSoapServiceToExpress( title: "Albums", }, { - itemType: "container", + itemType: "playlist", id: "playlists", title: "Playlists", + attributes: { + readOnly: false, + userContent: true, + renameable: false, + }, }, { itemType: "container", @@ -616,7 +660,7 @@ function bindSmapiSoapServiceToExpress( case "playlist": return musicLibrary .playlist(typeId!) - .then(playlist => playlist.entries) + .then((playlist) => playlist.entries) .then(slice2(paging)) .then(([page, total]) => { return getMetadataResult({ @@ -669,10 +713,77 @@ function bindSmapiSoapServiceToExpress( }); }); default: - throw `Unsupported id:${id}`; + throw `Unsupported getMetadata id=${id}`; } - } - ), + }), + createContainer: async ( + { title, seedId }: { title: string; seedId: string | undefined }, + _, + headers?: SoapyHeaders + ) => + auth(musicService, accessTokens, headers) + .then(({ musicLibrary }) => + musicLibrary + .createPlaylist(title) + .then((playlist) => ({ playlist, musicLibrary })) + ) + .then(({ musicLibrary, playlist }) => { + if (seedId) { + musicLibrary.addToPlaylist( + playlist.id, + seedId.split(":")[1]! + ); + } + return playlist; + }) + .then((it) => ({ + createContainerResult: { + id: `playlist:${it.id}`, + updateId: "", + }, + })), + deleteContainer: async ( + { id }: { id: string }, + _, + headers?: SoapyHeaders + ) => + auth(musicService, accessTokens, headers) + .then(({ musicLibrary }) => musicLibrary.deletePlaylist(id)) + .then((_) => ({ deleteContainerResult: {} })), + addToContainer: async ( + { id, parentId }: { id: string; parentId: string }, + _, + headers?: SoapyHeaders + ) => + auth(musicService, accessTokens, headers) + .then(splitId(id)) + .then(({ musicLibrary, typeId }) => + musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId) + ) + .then((_) => ({ addToContainerResult: { updateId: "" } })), + removeFromContainer: async ( + { id, indices }: { id: string; indices: string }, + _, + headers?: SoapyHeaders + ) => + auth(musicService, accessTokens, headers) + .then(splitId(id)) + .then((it) => ({ + ...it, + indices: indices.split(",").map((it) => +it), + })) + .then(({ musicLibrary, typeId, indices }) => { + if (id == "playlists") { + musicLibrary.playlists().then((it) => { + indices.forEach((i) => { + musicLibrary.deletePlaylist(it[i]?.id!); + }); + }); + } else { + musicLibrary.removeFromPlaylist(typeId, indices); + } + }) + .then((_) => ({ removeFromContainerResult: { updateId: "" } })), }, }, }, diff --git a/src/sonos.ts b/src/sonos.ts index 7003c1a..70e47ee 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -7,7 +7,7 @@ import logger from "./logger"; import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi"; import qs from "querystring" -export const PRESENTATION_AND_STRINGS_VERSION = "15"; +export const PRESENTATION_AND_STRINGS_VERSION = "18"; export type Capability = | "search" @@ -22,7 +22,7 @@ export const BONOB_CAPABILITIES: Capability[] = [ "search", // "trFavorites", // "alFavorites", - // "ucPlaylists", + "ucPlaylists", "extendedMD", ]; diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 582d9b9..f49662b 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -136,6 +136,10 @@ export class InMemoryMusicService implements MusicService { playlists: async () => Promise.resolve([]), playlist: async (id: string) => Promise.reject(`No playlist with id ${id}`), + createPlaylist: async (_: string) => Promise.reject("Unsupported operation"), + deletePlaylist: async (_: string) => Promise.reject("Unsupported operation"), + addToPlaylist: async (_: string) => Promise.reject("Unsupported operation"), + removeFromPlaylist: async (_: string, _2: number[]) => Promise.reject("Unsupported operation"), }); } diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index ea5ffb9..91b20e9 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -9,6 +9,7 @@ import { DODGY_IMAGE_NAME, asGenre, appendMimeTypeToClientFor, + asURLSearchParams } from "../src/navidrome"; import encryption from "../src/encryption"; @@ -107,6 +108,52 @@ describe("appendMimeTypeToUserAgentFor", () => { }); }); +describe("asURLSearchParams", () => { + describe("empty q", () => { + it("should return empty params", () => { + const q = {}; + const expected = new URLSearchParams(); + expect(asURLSearchParams(q)).toEqual(expected); + }); + }); + + describe("singular params", () => { + it("should append each", () => { + const q = { + a: 1, + b: "bee", + c: false, + d: true + }; + const expected = new URLSearchParams(); + expected.append("a", "1"); + expected.append("b", "bee"); + expected.append("c", "false"); + expected.append("d", "true"); + + expect(asURLSearchParams(q)).toEqual(expected); + }); + }); + + describe("list params", () => { + it("should append each", () => { + const q = { + a: [1, "two", false, true], + b: "yippee" + }; + + const expected = new URLSearchParams(); + expected.append("a", "1"); + expected.append("a", "two"); + expected.append("a", "false"); + expected.append("a", "true"); + expected.append("b", "yippee"); + + expect(asURLSearchParams(q)).toEqual(expected); + }); + }); +}); + const ok = (data: string) => ({ status: 200, data, @@ -246,6 +293,10 @@ const getPlayLists = ( const error = (code: string, message: string) => ``; +const createPlayList = (playlist: PlaylistSummary) => ` + ${playlistXml(playlist)} + ` + const getPlayList = ( playlist: Playlist ) => ` @@ -338,10 +389,10 @@ describe("Navidrome", () => { const authParams = { u: username, - t: t(password, salt), - s: salt, v: "1.16.1", c: "bonob", + t: t(password, salt), + s: salt, }; const headers = { "User-Agent": "bonob", @@ -362,7 +413,7 @@ describe("Navidrome", () => { expect(token.userId).toEqual(username); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { - params: authParams, + params: asURLSearchParams(authParams), headers, }); }); @@ -403,9 +454,7 @@ describe("Navidrome", () => { expect(result).toEqual(genres.map(asGenre)); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: { - ...authParams, - }, + params: asURLSearchParams(authParams), headers, }); }); @@ -429,9 +478,7 @@ describe("Navidrome", () => { expect(result).toEqual(genres.map(asGenre)); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: { - ...authParams, - }, + params: asURLSearchParams(authParams), headers, }); }); @@ -489,18 +536,18 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artist.id, + params:asURLSearchParams( { ...authParams, - }, + id: artist.id, + }), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); }); @@ -552,18 +599,18 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); }); @@ -615,18 +662,18 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); }); @@ -678,18 +725,18 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); }); @@ -732,18 +779,18 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); }); @@ -784,18 +831,18 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); }); @@ -834,18 +881,18 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist.id, + params: asURLSearchParams({ ...authParams, - }, + id: artist.id, + }), headers, }); }); @@ -939,7 +986,7 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: authParams, + params: asURLSearchParams(authParams), headers, }); }); @@ -967,7 +1014,7 @@ describe("Navidrome", () => { expect(artists).toEqual({ results: expectedResults, total: 4 }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: authParams, + params: asURLSearchParams(authParams), headers, }); }); @@ -1018,13 +1065,13 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { - params: { + params: asURLSearchParams({ + ...authParams, type: "byGenre", genre: "Pop", size: 500, offset: 0, - ...authParams, - }, + }), headers, }); }); @@ -1060,12 +1107,12 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { - params: { + params: asURLSearchParams({ + ...authParams, type: "newest", size: 500, offset: 0, - ...authParams, - }, + }), headers, }); }); @@ -1101,12 +1148,12 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { - params: { + params: asURLSearchParams({ + ...authParams, type: "recent", size: 500, offset: 0, - ...authParams, - }, + }), headers, }); }); @@ -1135,12 +1182,12 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { - params: { + params: asURLSearchParams({ + ...authParams, type: "frequent", size: 500, offset: 0, - ...authParams, - }, + }), headers, }); }); @@ -1181,12 +1228,12 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { - params: { + params: asURLSearchParams({ + ...authParams, type: "alphabeticalByArtist", size: 500, offset: 0, - ...authParams, - }, + }), headers, }); }); @@ -1226,12 +1273,12 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { - params: { + params: asURLSearchParams({ + ...authParams, type: "alphabeticalByArtist", size: 500, offset: 0, - ...authParams, - }, + }), headers, }); }); @@ -1288,12 +1335,12 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { - params: { + params: asURLSearchParams({ + ...authParams, type: "alphabeticalByArtist", size: 500, offset: 0, - ...authParams, - }, + }), headers, }); }); @@ -1318,12 +1365,12 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { - params: { + params: asURLSearchParams({ + ...authParams, type: "alphabeticalByArtist", size: 2, offset: 2, - ...authParams, - }, + }), headers, }); }); @@ -1374,12 +1421,12 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { - params: { + params: asURLSearchParams({ + ...authParams, type: "alphabeticalByArtist", size: 500, offset: 0, - ...authParams, - }, + }), headers, }); }); @@ -1420,10 +1467,10 @@ describe("Navidrome", () => { expect(result).toEqual(album); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: { - id: album.id, + params: asURLSearchParams({ ...authParams, - }, + id: album.id, + }), headers, }); }); @@ -1485,10 +1532,10 @@ describe("Navidrome", () => { expect(result).toEqual(tracks); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: { - id: album.id, + params: asURLSearchParams({ ...authParams, - }, + id: album.id, + }), headers, }); }); @@ -1535,10 +1582,10 @@ describe("Navidrome", () => { expect(result).toEqual(tracks); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: { - id: album.id, + params: asURLSearchParams({ ...authParams, - }, + id: album.id, + }), headers, }); }); @@ -1573,10 +1620,10 @@ describe("Navidrome", () => { expect(result).toEqual([]); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: { - id: album.id, + params: asURLSearchParams({ ...authParams, - }, + id: album.id, + }), headers, }); }); @@ -1619,18 +1666,18 @@ describe("Navidrome", () => { expect(result).toEqual(track); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { - params: { - id: track.id, + params: asURLSearchParams({ ...authParams, - }, + id: track.id, + }), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: { - id: album.id, + params: asURLSearchParams({ ...authParams, - }, + id: album.id, + }), headers, }); }); @@ -1783,10 +1830,10 @@ describe("Navidrome", () => { expect(result.stream).toEqual(stream); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: { - id: trackId, + params: asURLSearchParams({ ...authParams, - }, + id: trackId, + }), headers: { "User-Agent": "bonob", }, @@ -1869,10 +1916,10 @@ describe("Navidrome", () => { expect(result.stream).toEqual(stream); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: { - id: trackId, + params: asURLSearchParams({ ...authParams, - }, + id: trackId, + }), headers: { "User-Agent": "bonob", Range: range, @@ -1915,11 +1962,11 @@ describe("Navidrome", () => { expect(streamClientApplication).toHaveBeenCalledWith(track); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: { - id: trackId, + params: asURLSearchParams({ ...authParams, + id: trackId, c: clientApplication, - }, + }), headers: { "User-Agent": "bonob", }, @@ -1960,11 +2007,11 @@ describe("Navidrome", () => { expect(streamClientApplication).toHaveBeenCalledWith(track); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params: { - id: trackId, + params:asURLSearchParams( { ...authParams, + id: trackId, c: clientApplication, - }, + }), headers: { "User-Agent": "bonob", Range: range, @@ -2005,10 +2052,10 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { - params: { - id: coverArtId, + params: asURLSearchParams({ ...authParams, - }, + id: coverArtId + }), headers, responseType: "arraybuffer", }); @@ -2043,11 +2090,11 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { - params: { + params: asURLSearchParams({ + ...authParams, id: coverArtId, size, - ...authParams, - }, + }), headers, responseType: "arraybuffer", }); @@ -2101,10 +2148,10 @@ describe("Navidrome", () => { expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, } ); @@ -2166,20 +2213,20 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, } ); @@ -2187,10 +2234,10 @@ describe("Navidrome", () => { expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getCoverArt`, { - params: { - id: album1.id, + params: asURLSearchParams({ ...authParams, - }, + id: album1.id, + }), headers, responseType: "arraybuffer", } @@ -2241,20 +2288,20 @@ describe("Navidrome", () => { expect(result).toBeUndefined(); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, } ); @@ -2319,10 +2366,10 @@ describe("Navidrome", () => { expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, } ); @@ -2387,20 +2434,20 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, } ); @@ -2408,11 +2455,11 @@ describe("Navidrome", () => { expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getCoverArt`, { - params: { + params: asURLSearchParams({ + ...authParams, id: album1.id, size, - ...authParams, - }, + }), headers, responseType: "arraybuffer", } @@ -2463,20 +2510,20 @@ describe("Navidrome", () => { expect(result).toBeUndefined(); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, } ); @@ -2534,20 +2581,20 @@ describe("Navidrome", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, } ); @@ -2555,11 +2602,11 @@ describe("Navidrome", () => { expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getCoverArt`, { - params: { + params: asURLSearchParams({ + ...authParams, id: album1.id, size, - ...authParams, - }, + }), headers, responseType: "arraybuffer", } @@ -2610,20 +2657,20 @@ describe("Navidrome", () => { expect(result).toBeUndefined(); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, }); expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo`, { - params: { - id: artistId, + params: asURLSearchParams({ ...authParams, - }, + id: artistId, + }), headers, } ); @@ -2652,11 +2699,11 @@ describe("Navidrome", () => { expect(result).toEqual(true); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: { + params: asURLSearchParams({ + ...authParams, id, submission: true, - ...authParams, - }, + }), headers, }); }); @@ -2684,11 +2731,11 @@ describe("Navidrome", () => { expect(result).toEqual(false); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { - params: { + params: asURLSearchParams({ + ...authParams, id, submission: true, - ...authParams, - }, + }), headers, }); }); @@ -2715,13 +2762,13 @@ describe("Navidrome", () => { expect(result).toEqual([artistToArtistSummary(artist1)]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: { - query: "foo", + params: asURLSearchParams({ + ...authParams, artistCount: 20, albumCount: 0, songCount: 0, - ...authParams, - }, + query: "foo", + }), headers, }); }); @@ -2750,13 +2797,13 @@ describe("Navidrome", () => { ]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: { - query: "foo", + params: asURLSearchParams({ + ...authParams, artistCount: 20, albumCount: 0, songCount: 0, - ...authParams, - }, + query: "foo", + }), headers, }); }); @@ -2779,13 +2826,13 @@ describe("Navidrome", () => { expect(result).toEqual([]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: { - query: "foo", + params: asURLSearchParams({ + ...authParams, artistCount: 20, albumCount: 0, songCount: 0, - ...authParams, - }, + query: "foo", + }), headers, }); }); @@ -2816,13 +2863,13 @@ describe("Navidrome", () => { expect(result).toEqual([albumToAlbumSummary(album)]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: { - query: "foo", - albumCount: 20, - artistCount: 0, - songCount: 0, + params: asURLSearchParams({ ...authParams, - }, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "foo", + }), headers, }); }); @@ -2869,13 +2916,13 @@ describe("Navidrome", () => { ]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: { - query: "moo", - albumCount: 20, - artistCount: 0, - songCount: 0, + params: asURLSearchParams({ ...authParams, - }, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "moo", + }), headers, }); }); @@ -2898,13 +2945,13 @@ describe("Navidrome", () => { expect(result).toEqual([]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: { - query: "foo", - albumCount: 20, - artistCount: 0, - songCount: 0, + params: asURLSearchParams({ ...authParams, - }, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "foo", + }), headers, }); }); @@ -2947,13 +2994,13 @@ describe("Navidrome", () => { expect(result).toEqual([track]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: { - query: "foo", - songCount: 20, + params: asURLSearchParams({ + ...authParams, artistCount: 0, albumCount: 0, - ...authParams, - }, + songCount: 20, + query: "foo", + }), headers, }); }); @@ -3018,13 +3065,13 @@ describe("Navidrome", () => { expect(result).toEqual([track1, track2]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: { - query: "moo", - songCount: 20, + params: asURLSearchParams({ + ...authParams, artistCount: 0, albumCount: 0, - ...authParams, - }, + songCount: 20, + query: "moo", + }), headers, }); }); @@ -3047,13 +3094,13 @@ describe("Navidrome", () => { expect(result).toEqual([]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { - params: { - query: "foo", - songCount: 20, + params: asURLSearchParams({ + ...authParams, artistCount: 0, albumCount: 0, - ...authParams, - }, + songCount: 20, + query: "foo", + }), headers, }); }); @@ -3081,7 +3128,7 @@ describe("Navidrome", () => { expect(result).toEqual([playlist]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: authParams, + params: asURLSearchParams(authParams), headers, }); }); @@ -3109,7 +3156,7 @@ describe("Navidrome", () => { expect(result).toEqual(playlists); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: authParams, + params: asURLSearchParams(authParams), headers, }); }); @@ -3132,7 +3179,7 @@ describe("Navidrome", () => { expect(result).toEqual([]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: authParams, + params: asURLSearchParams(authParams), headers, }); }); @@ -3204,10 +3251,10 @@ describe("Navidrome", () => { }); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { - params: { - id, + params: asURLSearchParams({ ...authParams, - }, + id, + }), headers, }); }); @@ -3234,15 +3281,134 @@ describe("Navidrome", () => { expect(result).toEqual(playlist); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { - params: { - id: playlist.id, + params: asURLSearchParams({ ...authParams, - }, + id: playlist.id, + }), headers, }); }); }); }); }); + + describe("creating a playlist", () => { + it("should create a playlist with the given name", async () => { + const name = "ThePlaylist"; + const id = uuid(); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(createPlayList({id, name}))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.createPlaylist(name)); + + expect(result).toEqual({ id, name }); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/createPlaylist`, { + params: asURLSearchParams({ + ...authParams, + name, + }), + headers, + }); + }); + }); + + describe("deleting a playlist", () => { + it("should delete the playlist by id", async () => { + const id = "id-to-delete"; + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(EMPTY)) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.deletePlaylist(id)); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/deletePlaylist`, { + params: asURLSearchParams({ + ...authParams, + id, + }), + headers, + }); + }); + }); + + describe("editing playlists", () => { + describe("adding a track to a playlist", () => { + it("should add it", async () => { + const playlistId = uuid(); + const trackId = uuid(); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(EMPTY)) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.addToPlaylist(playlistId, trackId)); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { + params: asURLSearchParams({ + ...authParams, + playlistId, + songIdToAdd: trackId, + }), + headers, + }); + }); + }); + + describe("removing a track from a playlist", () => { + it("should remove it", async () => { + const playlistId = uuid(); + const indicies =[6, 100, 33]; + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(EMPTY)) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.removeFromPlaylist(playlistId, indicies)); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { + params: asURLSearchParams({ + ...authParams, + playlistId, + songIndexToRemove: indicies, + }), + headers, + }); + }); + }); + }); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 83c4ea1..a12b78b 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -35,6 +35,7 @@ import { ROCK, TRIP_HOP, PUNK, + aPlaylist, } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import supersoap from "./supersoap"; @@ -296,6 +297,10 @@ describe("api", () => { searchArtists: jest.fn(), searchAlbums: jest.fn(), searchTracks: jest.fn(), + createPlaylist: jest.fn(), + addToPlaylist: jest.fn(), + deletePlaylist: jest.fn(), + removeFromPlaylist: jest.fn(), }; const accessTokens = { mint: jest.fn(), @@ -309,9 +314,9 @@ describe("api", () => { SONOS_DISABLED, service, rootUrl, - (musicService as unknown) as MusicService, - (linkCodes as unknown) as LinkCodes, - (accessTokens as unknown) as AccessTokens, + musicService as unknown as MusicService, + linkCodes as unknown as LinkCodes, + accessTokens as unknown as AccessTokens, clock ); @@ -498,9 +503,10 @@ describe("api", () => { expect(result[0]).toEqual({ getLastUpdateResult: { + autoRefreshEnabled: true, favorites: `${now.unix()}`, catalog: `${now.unix()}`, - pollInterval: 120, + pollInterval: 60, }, }); }); @@ -730,9 +736,14 @@ describe("api", () => { { itemType: "container", id: "artists", title: "Artists" }, { itemType: "albumList", id: "albums", title: "Albums" }, { - itemType: "container", + itemType: "playlist", id: "playlists", title: "Playlists", + attributes: { + readOnly: "false", + renameable: "false", + userContent: "true", + }, }, { itemType: "container", id: "genres", title: "Genres" }, { @@ -861,10 +872,15 @@ describe("api", () => { expect(result[0]).toEqual( getMetadataResult({ mediaCollection: expectedPlayLists.map((playlist) => ({ - itemType: "album", + itemType: "playlist", id: `playlist:${playlist.id}`, title: playlist.name, canPlay: true, + attributes: { + readOnly: "false", + userContent: "false", + renameable: "false", + }, })), index: 0, total: expectedPlayLists.length, @@ -886,10 +902,15 @@ describe("api", () => { expectedPlayLists[1]!, expectedPlayLists[2]!, ].map((playlist) => ({ - itemType: "album", + itemType: "playlist", id: `playlist:${playlist.id}`, title: playlist.name, canPlay: true, + attributes: { + readOnly: "false", + userContent: "false", + renameable: "false", + }, })), index: 1, total: expectedPlayLists.length, @@ -1662,8 +1683,8 @@ describe("api", () => { const playlist = { id: uuid(), name: "playlist for test", - entries: [track1, track2, track3, track4, track5] - } + entries: [track1, track2, track3, track4, track5], + }; beforeEach(() => { musicLibrary.playlist.mockResolvedValue(playlist); @@ -1720,7 +1741,7 @@ describe("api", () => { expect(musicLibrary.playlist).toHaveBeenCalledWith(playlist.id); }); }); - }); + }); }); }); @@ -1916,6 +1937,44 @@ describe("api", () => { }); }); }); + + describe("asking for a track", () => { + it("should return the albums", async () => { + const track = aTrack(); + + musicLibrary.track.mockResolvedValue(track); + + const root = await ws.getExtendedMetadataAsync({ + id: `track:${track.id}`, + }); + + expect(root[0]).toEqual({ + getExtendedMetadataResult: { + mediaMetadata: { + id: `track:${track.id}`, + itemType: "track", + title: track.name, + mimeType: track.mimeType, + trackMetadata: { + artistId: track.artist.id, + artist: track.artist.name, + albumId: track.album.id, + album: track.album.name, + genre: track.genre?.name, + genreId: track.genre?.id, + duration: track.duration, + albumArtURI: defaultAlbumArtURI( + rootUrl, + accessToken, + track.album + ), + }, + }, + }, + }); + expect(musicLibrary.track).toHaveBeenCalledWith(track.id); + }); + }); }); }); @@ -2079,5 +2138,202 @@ describe("api", () => { }); }); }); + + describe("createContainer", () => { + const authToken = `authToken-${uuid()}`; + const accessToken = `accessToken-${uuid()}`; + + let ws: Client; + + beforeEach(async () => { + musicService.login.mockResolvedValue(musicLibrary); + accessTokens.mint.mockReturnValue(accessToken); + + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + ws.addSoapHeader({ credentials: someCredentials(authToken) }); + }); + + describe("with only a title", () => { + const title = "aNewPlaylist"; + const idOfNewPlaylist = uuid(); + + it("should create a playlist", async () => { + musicLibrary.createPlaylist.mockResolvedValue({ id: idOfNewPlaylist, name: title }); + + const result = await ws.createContainerAsync({ + title, + }); + + expect(result[0]).toEqual({ + createContainerResult: { + id: `playlist:${idOfNewPlaylist}`, + updateId: null, + }, + }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.createPlaylist).toHaveBeenCalledWith(title); + }); + }); + + describe("with a title and a seed track", () => { + const title = "aNewPlaylist2"; + const trackId = 'track123'; + const idOfNewPlaylist = 'playlistId'; + + it("should create a playlist with the track", async () => { + musicLibrary.createPlaylist.mockResolvedValue({ id: idOfNewPlaylist, name: title }); + musicLibrary.addToPlaylist.mockResolvedValue(true); + + const result = await ws.createContainerAsync({ + title, + seedId: `track:${trackId}` + }); + + expect(result[0]).toEqual({ + createContainerResult: { + id: `playlist:${idOfNewPlaylist}`, + updateId: null, + }, + }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.createPlaylist).toHaveBeenCalledWith(title); + expect(musicLibrary.addToPlaylist).toHaveBeenCalledWith(idOfNewPlaylist, trackId); + }); + + }); + }); + + describe("deleteContainer", () => { + const authToken = `authToken-${uuid()}`; + const accessToken = `accessToken-${uuid()}`; + const id = "id123"; + + let ws: Client; + + beforeEach(async () => { + musicService.login.mockResolvedValue(musicLibrary); + accessTokens.mint.mockReturnValue(accessToken); + + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + ws.addSoapHeader({ credentials: someCredentials(authToken) }); + }); + + it("should delete the playlist", async () => { + musicLibrary.deletePlaylist.mockResolvedValue(true); + + const result = await ws.deleteContainerAsync({ + id, + }); + + expect(result[0]).toEqual({ deleteContainerResult: null }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.deletePlaylist).toHaveBeenCalledWith(id); + }); + + describe("addToContainer", () => { + const authToken = `authToken-${uuid()}`; + const accessToken = `accessToken-${uuid()}`; + const trackId = "track123"; + const playlistId = "parent123"; + + let ws: Client; + + beforeEach(async () => { + musicService.login.mockResolvedValue(musicLibrary); + accessTokens.mint.mockReturnValue(accessToken); + + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + ws.addSoapHeader({ credentials: someCredentials(authToken) }); + }); + + it("should delete the playlist", async () => { + musicLibrary.addToPlaylist.mockResolvedValue(true); + + const result = await ws.addToContainerAsync({ + id: `track:${trackId}`, + parentId: `parent:${playlistId}` + }); + + expect(result[0]).toEqual({ addToContainerResult: { updateId: null } }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.addToPlaylist).toHaveBeenCalledWith(playlistId, trackId); + }); + }); + + describe("removeFromContainer", () => { + const authToken = `authToken-${uuid()}`; + const accessToken = `accessToken-${uuid()}`; + + let ws: Client; + + beforeEach(async () => { + musicService.login.mockResolvedValue(musicLibrary); + accessTokens.mint.mockReturnValue(accessToken); + + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + ws.addSoapHeader({ credentials: someCredentials(authToken) }); + }); + + describe("removing tracks from a playlist", () => { + const playlistId = "parent123"; + + it("should remove the track from playlist", async () => { + musicLibrary.removeFromPlaylist.mockResolvedValue(true); + + const result = await ws.removeFromContainerAsync({ + id: `playlist:${playlistId}`, + indices: `1,6,9` + }); + + expect(result[0]).toEqual({ removeFromContainerResult: { updateId: null } }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.removeFromPlaylist).toHaveBeenCalledWith(playlistId, [1,6,9]); + }); + }); + + describe("removing a playlist", () => { + const playlist1 = aPlaylist({ id: 'p1' }); + const playlist2 = aPlaylist({ id: 'p2' }); + const playlist3 = aPlaylist({ id: 'p3' }); + const playlist4 = aPlaylist({ id: 'p4' }); + const playlist5 = aPlaylist({ id: 'p5' }); + + it("should delete the playlist", async () => { + musicLibrary.playlists.mockResolvedValue([playlist1, playlist2, playlist3, playlist4, playlist5]); + musicLibrary.deletePlaylist.mockResolvedValue(true); + + const result = await ws.removeFromContainerAsync({ + id: `playlists`, + indices: `0,2,4` + }); + + expect(result[0]).toEqual({ removeFromContainerResult: { updateId: null } }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.deletePlaylist).toHaveBeenCalledTimes(3); + expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith(1, playlist1.id); + expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith(2, playlist3.id); + expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith(3, playlist5.id); + }); + }); + }); + }); }); });