mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Ability to edit navidrome playlists from sonos app
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -174,4 +174,8 @@ export interface MusicLibrary {
|
||||
searchTracks(query: string): Promise<Track[]>;
|
||||
playlists(): Promise<PlaylistSummary[]>;
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -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<GetSongResponse>(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<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);
|
||||
|
||||
171
src/smapi.ts
171
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<T>(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<Album>(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: "" } })),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user