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

@@ -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>
}

View File

@@ -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);

View File

@@ -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: "" } })),
},
},
},

View File

@@ -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",
];