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
|
- 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
|
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
171
src/smapi.ts
171
src/smapi.ts
@@ -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: "" } })),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user