Ability to edit navidrome playlists from sonos app

This commit is contained in:
simojenki
2021-06-12 11:06:21 +10:00
parent 7587cae467
commit d77f04bb43
8 changed files with 858 additions and 285 deletions

View File

@@ -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 - Transcoding performed by Navidrome with specific player for bonob/sonos, customisable by mimeType
- Ability to search by Album, Artist, Track - Ability to search by Album, Artist, Track
- Ability to play a playlist - Ability to play a playlist
- Ability to add/remove playlists
- Ability to add/remove tracks from a playlist
## Running ## Running
@@ -113,4 +115,3 @@ BONOB_NAVIDROME_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for cust
## TODO ## TODO
- Artist Radio - Artist Radio
- Add tracks to playlists

View File

@@ -174,4 +174,8 @@ export interface MusicLibrary {
searchTracks(query: string): Promise<Track[]>; searchTracks(query: string): Promise<Track[]>;
playlists(): Promise<PlaylistSummary[]>; playlists(): Promise<PlaylistSummary[]>;
playlist(id: string): Promise<Playlist>; playlist(id: string): Promise<Playlist>;
createPlaylist(name: string): Promise<PlaylistSummary>
deletePlaylist(id: string): Promise<boolean>
addToPlaylist(playlistId: string, trackId: string): Promise<boolean>
removeFromPlaylist(playlistId: string, indicies: number[]): Promise<boolean>
} }

View File

@@ -21,7 +21,7 @@ import {
} from "./music_service"; } from "./music_service";
import X2JS from "x2js"; import X2JS from "x2js";
import sharp from "sharp"; import sharp from "sharp";
import { pick } from "underscore"; import _, { pick } from "underscore";
import axios, { AxiosRequestConfig } from "axios"; import axios, { AxiosRequestConfig } from "axios";
import { Encryption } from "./encryption"; import { Encryption } from "./encryption";
@@ -278,6 +278,16 @@ export function appendMimeTypeToClientFor(mimeTypes: string[]) {
mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob"; 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 { export class Navidrome implements MusicService {
url: string; url: string;
encryption: Encryption; encryption: Encryption;
@@ -301,13 +311,13 @@ export class Navidrome implements MusicService {
) => ) =>
axios axios
.get(`${this.url}${path}`, { .get(`${this.url}${path}`, {
params: { params: asURLSearchParams({
u: username, u: username,
v: "1.16.1", v: "1.16.1",
c: DEFAULT_CLIENT_APPLICATION, c: DEFAULT_CLIENT_APPLICATION,
...t_and_s(password), ...t_and_s(password),
...q, ...q,
}, }),
headers: { headers: {
"User-Agent": USER_AGENT, "User-Agent": USER_AGENT,
}, },
@@ -345,7 +355,7 @@ export class Navidrome implements MusicService {
.then((json) => json["subsonic-response"]) .then((json) => json["subsonic-response"])
.then((json) => { .then((json) => {
if (isError(json)) throw json.error._message; 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) => generateToken = async (credentials: Credentials) =>
@@ -437,15 +447,10 @@ export class Navidrome implements MusicService {
})); }));
getCoverArt = (credentials: Credentials, id: string, size?: number) => getCoverArt = (credentials: Credentials, id: string, size?: number) =>
this.get( this.get(credentials, "/rest/getCoverArt", size ? { id, size } : { id }, {
credentials, headers: { "User-Agent": "bonob" },
"/rest/getCoverArt", responseType: "arraybuffer",
{ id, size }, });
{
headers: { "User-Agent": "bonob" },
responseType: "arraybuffer",
}
);
getTrack = (credentials: Credentials, id: string) => getTrack = (credentials: Credentials, id: string) =>
this.getJSON<GetSongResponse>(credentials, "/rest/getSong", { this.getJSON<GetSongResponse>(credentials, "/rest/getSong", {
@@ -674,7 +679,6 @@ export class Navidrome implements MusicService {
name: entry._album, name: entry._album,
year: entry._year, year: entry._year,
genre: maybeAsGenre(entry._genre), genre: maybeAsGenre(entry._genre),
artistName: entry._artist, artistName: entry._artist,
artistId: entry._artistId, artistId: entry._artistId,
}, },
@@ -683,8 +687,35 @@ export class Navidrome implements MusicService {
name: entry._artist, name: entry._artist,
}, },
})), })),
} };
}), }),
createPlaylist: async (name: string) =>
navidrome
.getJSON<GetPlaylistResponse>(credentials, "/rest/createPlaylist", {
name,
})
.then((it) => it.playlist)
.then((it) => ({ id: it._id, name: it._name })),
deletePlaylist: async (id: string) =>
navidrome
.getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", {
id,
})
.then((_) => true),
addToPlaylist: async (playlistId: string, trackId: string) =>
navidrome
.getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", {
playlistId,
songIdToAdd: trackId,
})
.then((_) => true),
removeFromPlaylist: async (playlistId: string, indicies: number[]) =>
navidrome
.getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", {
playlistId,
songIndexToRemove: indicies,
})
.then((_) => true),
}; };
return Promise.resolve(musicLibrary); return Promise.resolve(musicLibrary);

View File

@@ -205,10 +205,15 @@ const genre = (genre: Genre) => ({
}); });
const playlist = (playlist: PlaylistSummary) => ({ const playlist = (playlist: PlaylistSummary) => ({
itemType: "album", itemType: "playlist",
id: `playlist:${playlist.id}`, id: `playlist:${playlist.id}`,
title: playlist.name, title: playlist.name,
canPlay: true, canPlay: true,
attributes: {
readOnly: false,
userContent: false,
renameable: false,
},
}); });
export const defaultAlbumArtURI = ( export const defaultAlbumArtURI = (
@@ -279,7 +284,6 @@ export const artist = (
const auth = async ( const auth = async (
musicService: MusicService, musicService: MusicService,
accessTokens: AccessTokens, accessTokens: AccessTokens,
id: string,
headers?: SoapyHeaders headers?: SoapyHeaders
) => { ) => {
if (!headers?.credentials) { if (!headers?.credentials) {
@@ -292,15 +296,13 @@ const auth = async (
} }
const authToken = headers.credentials.loginToken.token; const authToken = headers.credentials.loginToken.token;
const accessToken = accessTokens.mint(authToken); const accessToken = accessTokens.mint(authToken);
const [type, typeId] = id.split(":");
return musicService return musicService
.login(authToken) .login(authToken)
.then((musicLibrary) => ({ .then((musicLibrary) => ({
musicLibrary, musicLibrary,
authToken, authToken,
accessToken, accessToken,
type,
typeId,
})) }))
.catch((_) => { .catch((_) => {
throw { throw {
@@ -312,6 +314,15 @@ const auth = async (
}); });
}; };
function splitId<T>(id: string) {
const [type, typeId] = id.split(":");
return (t: T) => ({
...t,
type,
typeId: typeId!,
});
}
type SoapyHeaders = { type SoapyHeaders = {
credentials?: Credentials; credentials?: Credentials;
}; };
@@ -337,9 +348,10 @@ function bindSmapiSoapServiceToExpress(
sonosSoap.getDeviceAuthToken({ linkCode }), sonosSoap.getDeviceAuthToken({ linkCode }),
getLastUpdate: () => ({ getLastUpdate: () => ({
getLastUpdateResult: { getLastUpdateResult: {
autoRefreshEnabled: true,
favorites: clock.now().unix(), favorites: clock.now().unix(),
catalog: clock.now().unix(), catalog: clock.now().unix(),
pollInterval: 120, pollInterval: 60,
}, },
}), }),
getMediaURI: async ( getMediaURI: async (
@@ -347,8 +359,9 @@ function bindSmapiSoapServiceToExpress(
_, _,
headers?: SoapyHeaders headers?: SoapyHeaders
) => ) =>
auth(musicService, accessTokens, id, headers).then( auth(musicService, accessTokens, headers)
({ accessToken, type, typeId }) => ({ .then(splitId(id))
.then(({ accessToken, type, typeId }) => ({
getMediaURIResult: `${webAddress}/stream/${type}/${typeId}`, getMediaURIResult: `${webAddress}/stream/${type}/${typeId}`,
httpHeaders: [ httpHeaders: [
{ {
@@ -356,26 +369,27 @@ function bindSmapiSoapServiceToExpress(
value: accessToken, value: accessToken,
}, },
], ],
}) })),
),
getMediaMetadata: async ( getMediaMetadata: async (
{ id }: { id: string }, { id }: { id: string },
_, _,
headers?: SoapyHeaders headers?: SoapyHeaders
) => ) =>
auth(musicService, accessTokens, id, headers).then( auth(musicService, accessTokens, headers)
async ({ musicLibrary, accessToken, typeId }) => .then(splitId(id))
.then(async ({ musicLibrary, accessToken, typeId }) =>
musicLibrary.track(typeId!).then((it) => ({ musicLibrary.track(typeId!).then((it) => ({
getMediaMetadataResult: track(webAddress, accessToken, it), getMediaMetadataResult: track(webAddress, accessToken, it),
})) }))
), ),
search: async ( search: async (
{ id, term }: { id: string; term: string }, { id, term }: { id: string; term: string },
_, _,
headers?: SoapyHeaders headers?: SoapyHeaders
) => ) =>
auth(musicService, accessTokens, id, headers).then( auth(musicService, accessTokens, headers)
async ({ musicLibrary, accessToken }) => { .then(splitId(id))
.then(async ({ musicLibrary, accessToken }) => {
switch (id) { switch (id) {
case "albums": case "albums":
return musicLibrary.searchAlbums(term).then((it) => return musicLibrary.searchAlbums(term).then((it) =>
@@ -407,8 +421,7 @@ function bindSmapiSoapServiceToExpress(
default: default:
throw `Unsupported search by:${id}`; throw `Unsupported search by:${id}`;
} }
} }),
),
getExtendedMetadata: async ( getExtendedMetadata: async (
{ {
id, id,
@@ -419,12 +432,13 @@ function bindSmapiSoapServiceToExpress(
_, _,
headers?: SoapyHeaders headers?: SoapyHeaders
) => ) =>
auth(musicService, accessTokens, id, headers).then( auth(musicService, accessTokens, headers)
async ({ musicLibrary, accessToken, type, typeId }) => { .then(splitId(id))
.then(async ({ musicLibrary, accessToken, type, typeId }) => {
const paging = { _index: index, _count: count }; const paging = { _index: index, _count: count };
switch (type) { switch (type) {
case "artist": case "artist":
return musicLibrary.artist(typeId!).then((artist) => { return musicLibrary.artist(typeId).then((artist) => {
const [page, total] = slice2<Album>(paging)( const [page, total] = slice2<Album>(paging)(
artist.albums 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: default:
throw `Unsupported id:${id}`; throw `Unsupported getExtendedMetadata id=${id}`;
} }
} }),
),
getMetadata: async ( getMetadata: async (
{ {
id, id,
@@ -463,8 +501,9 @@ function bindSmapiSoapServiceToExpress(
_, _,
headers?: SoapyHeaders headers?: SoapyHeaders
) => ) =>
auth(musicService, accessTokens, id, headers).then( auth(musicService, accessTokens, headers)
({ musicLibrary, accessToken, type, typeId }) => { .then(splitId(id))
.then(({ musicLibrary, accessToken, type, typeId }) => {
const paging = { _index: index, _count: count }; const paging = { _index: index, _count: count };
logger.debug( logger.debug(
`Fetching metadata type=${type}, typeId=${typeId}` `Fetching metadata type=${type}, typeId=${typeId}`
@@ -496,9 +535,14 @@ function bindSmapiSoapServiceToExpress(
title: "Albums", title: "Albums",
}, },
{ {
itemType: "container", itemType: "playlist",
id: "playlists", id: "playlists",
title: "Playlists", title: "Playlists",
attributes: {
readOnly: false,
userContent: true,
renameable: false,
},
}, },
{ {
itemType: "container", itemType: "container",
@@ -616,7 +660,7 @@ function bindSmapiSoapServiceToExpress(
case "playlist": case "playlist":
return musicLibrary return musicLibrary
.playlist(typeId!) .playlist(typeId!)
.then(playlist => playlist.entries) .then((playlist) => playlist.entries)
.then(slice2(paging)) .then(slice2(paging))
.then(([page, total]) => { .then(([page, total]) => {
return getMetadataResult({ return getMetadataResult({
@@ -669,10 +713,77 @@ function bindSmapiSoapServiceToExpress(
}); });
}); });
default: 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: "" } })),
}, },
}, },
}, },

View File

@@ -7,7 +7,7 @@ import logger from "./logger";
import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi"; import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi";
import qs from "querystring" import qs from "querystring"
export const PRESENTATION_AND_STRINGS_VERSION = "15"; export const PRESENTATION_AND_STRINGS_VERSION = "18";
export type Capability = export type Capability =
| "search" | "search"
@@ -22,7 +22,7 @@ export const BONOB_CAPABILITIES: Capability[] = [
"search", "search",
// "trFavorites", // "trFavorites",
// "alFavorites", // "alFavorites",
// "ucPlaylists", "ucPlaylists",
"extendedMD", "extendedMD",
]; ];

View File

@@ -136,6 +136,10 @@ export class InMemoryMusicService implements MusicService {
playlists: async () => Promise.resolve([]), playlists: async () => Promise.resolve([]),
playlist: async (id: string) => playlist: async (id: string) =>
Promise.reject(`No playlist with id ${id}`), 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"),
}); });
} }

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@ import {
ROCK, ROCK,
TRIP_HOP, TRIP_HOP,
PUNK, PUNK,
aPlaylist,
} from "./builders"; } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryMusicService } from "./in_memory_music_service";
import supersoap from "./supersoap"; import supersoap from "./supersoap";
@@ -296,6 +297,10 @@ describe("api", () => {
searchArtists: jest.fn(), searchArtists: jest.fn(),
searchAlbums: jest.fn(), searchAlbums: jest.fn(),
searchTracks: jest.fn(), searchTracks: jest.fn(),
createPlaylist: jest.fn(),
addToPlaylist: jest.fn(),
deletePlaylist: jest.fn(),
removeFromPlaylist: jest.fn(),
}; };
const accessTokens = { const accessTokens = {
mint: jest.fn(), mint: jest.fn(),
@@ -309,9 +314,9 @@ describe("api", () => {
SONOS_DISABLED, SONOS_DISABLED,
service, service,
rootUrl, rootUrl,
(musicService as unknown) as MusicService, musicService as unknown as MusicService,
(linkCodes as unknown) as LinkCodes, linkCodes as unknown as LinkCodes,
(accessTokens as unknown) as AccessTokens, accessTokens as unknown as AccessTokens,
clock clock
); );
@@ -498,9 +503,10 @@ describe("api", () => {
expect(result[0]).toEqual({ expect(result[0]).toEqual({
getLastUpdateResult: { getLastUpdateResult: {
autoRefreshEnabled: true,
favorites: `${now.unix()}`, favorites: `${now.unix()}`,
catalog: `${now.unix()}`, catalog: `${now.unix()}`,
pollInterval: 120, pollInterval: 60,
}, },
}); });
}); });
@@ -730,9 +736,14 @@ describe("api", () => {
{ itemType: "container", id: "artists", title: "Artists" }, { itemType: "container", id: "artists", title: "Artists" },
{ itemType: "albumList", id: "albums", title: "Albums" }, { itemType: "albumList", id: "albums", title: "Albums" },
{ {
itemType: "container", itemType: "playlist",
id: "playlists", id: "playlists",
title: "Playlists", title: "Playlists",
attributes: {
readOnly: "false",
renameable: "false",
userContent: "true",
},
}, },
{ itemType: "container", id: "genres", title: "Genres" }, { itemType: "container", id: "genres", title: "Genres" },
{ {
@@ -861,10 +872,15 @@ describe("api", () => {
expect(result[0]).toEqual( expect(result[0]).toEqual(
getMetadataResult({ getMetadataResult({
mediaCollection: expectedPlayLists.map((playlist) => ({ mediaCollection: expectedPlayLists.map((playlist) => ({
itemType: "album", itemType: "playlist",
id: `playlist:${playlist.id}`, id: `playlist:${playlist.id}`,
title: playlist.name, title: playlist.name,
canPlay: true, canPlay: true,
attributes: {
readOnly: "false",
userContent: "false",
renameable: "false",
},
})), })),
index: 0, index: 0,
total: expectedPlayLists.length, total: expectedPlayLists.length,
@@ -886,10 +902,15 @@ describe("api", () => {
expectedPlayLists[1]!, expectedPlayLists[1]!,
expectedPlayLists[2]!, expectedPlayLists[2]!,
].map((playlist) => ({ ].map((playlist) => ({
itemType: "album", itemType: "playlist",
id: `playlist:${playlist.id}`, id: `playlist:${playlist.id}`,
title: playlist.name, title: playlist.name,
canPlay: true, canPlay: true,
attributes: {
readOnly: "false",
userContent: "false",
renameable: "false",
},
})), })),
index: 1, index: 1,
total: expectedPlayLists.length, total: expectedPlayLists.length,
@@ -1662,8 +1683,8 @@ describe("api", () => {
const playlist = { const playlist = {
id: uuid(), id: uuid(),
name: "playlist for test", name: "playlist for test",
entries: [track1, track2, track3, track4, track5] entries: [track1, track2, track3, track4, track5],
} };
beforeEach(() => { beforeEach(() => {
musicLibrary.playlist.mockResolvedValue(playlist); musicLibrary.playlist.mockResolvedValue(playlist);
@@ -1720,7 +1741,7 @@ describe("api", () => {
expect(musicLibrary.playlist).toHaveBeenCalledWith(playlist.id); 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);
});
});
});
});
}); });
}); });