mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-22 01:43:29 +01:00
Ability to edit navidrome playlists from sonos app
This commit is contained in:
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user