Files
bonob/src/smapi.ts
2021-03-19 20:31:39 +11:00

561 lines
16 KiB
TypeScript

import crypto from "crypto";
import { Express } from "express";
import { listen } from "soap";
import { readFileSync } from "fs";
import path from "path";
import logger from "./logger";
import { LinkCodes } from "./link_codes";
import {
Album,
AlbumSummary,
ArtistSummary,
Genre,
MusicLibrary,
MusicService,
slice2,
Track,
} from "./music_service";
import { AccessTokens } from "./access_tokens";
import { BONOB_ACCESS_TOKEN_HEADER } from "./server";
export const LOGIN_ROUTE = "/login";
export const SOAP_PATH = "/ws/sonos";
export const STRINGS_ROUTE = "/sonos/strings.xml";
export const PRESENTATION_MAP_ROUTE = "/sonos/presentationMap.xml";
export const SONOS_RECOMMENDED_IMAGE_SIZES = [
"60",
"80",
"120",
"180",
"192",
"200",
"230",
"300",
"600",
"640",
"750",
"1242",
"1500",
];
const WSDL_FILE = path.resolve(
__dirname,
"Sonoswsdl-1.19.4-20190411.142401-3.wsdl"
);
export type Credentials = {
loginToken: {
token: string;
householdId: string;
};
deviceId: string;
deviceProvider: string;
};
export type GetAppLinkResult = {
getAppLinkResult: {
authorizeAccount: {
appUrlStringId: string;
deviceLink: { regUrl: string; linkCode: string; showLinkCode: boolean };
};
};
};
export type GetDeviceAuthTokenResult = {
getDeviceAuthTokenResult: {
authToken: string;
privateKey: string;
userInfo: {
nickname: string;
userIdHashCode: string;
};
};
};
export type MediaCollection = {
id: string;
itemType: "collection";
title: string;
};
export type getMetadataResult = {
count: number;
index: number;
total: number;
mediaCollection?: any[];
mediaMetadata?: any[];
};
export type GetMetadataResponse = {
getMetadataResult: getMetadataResult;
};
export function getMetadataResult(
result: Partial<getMetadataResult>
): GetMetadataResponse {
const count =
(result?.mediaCollection?.length || 0) +
(result?.mediaMetadata?.length || 0);
return {
getMetadataResult: {
count,
index: 0,
total: count,
...result,
},
};
}
class SonosSoap {
linkCodes: LinkCodes;
webAddress: string;
constructor(webAddress: string, linkCodes: LinkCodes) {
this.webAddress = webAddress;
this.linkCodes = linkCodes;
}
getAppLink(): GetAppLinkResult {
const linkCode = this.linkCodes.mint();
return {
getAppLinkResult: {
authorizeAccount: {
appUrlStringId: "AppLinkMessage",
deviceLink: {
regUrl: `${this.webAddress}${LOGIN_ROUTE}?linkCode=${linkCode}`,
linkCode: linkCode,
showLinkCode: false,
},
},
},
};
}
getDeviceAuthToken({
linkCode,
}: {
linkCode: string;
}): GetDeviceAuthTokenResult {
const association = this.linkCodes.associationFor(linkCode);
if (association) {
return {
getDeviceAuthTokenResult: {
authToken: association.authToken,
privateKey: "",
userInfo: {
nickname: association.nickname,
userIdHashCode: crypto
.createHash("sha256")
.update(association.userId)
.digest("hex"),
},
},
};
} else {
throw {
Fault: {
faultcode: "Client.NOT_LINKED_RETRY",
faultstring: "Link Code not found retry...",
detail: {
ExceptionInfo: "NOT_LINKED_RETRY",
SonosError: "5",
},
},
};
}
}
}
export type Container = {
itemType: "container";
id: string;
title: string;
};
const container = ({
id,
title,
}: {
id: string;
title: string;
}): Container => ({
itemType: "container",
id,
title,
});
const genre = (genre: Genre) => ({
itemType: "container",
id: `genre:${genre.id}`,
title: genre.name,
});
export const defaultAlbumArtURI = (
webAddress: string,
accessToken: string,
album: AlbumSummary
) =>
`${webAddress}/album/${album.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`;
export const defaultArtistArtURI = (
webAddress: string,
accessToken: string,
artist: ArtistSummary
) =>
`${webAddress}/artist/${artist.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`;
export const album = (
webAddress: string,
accessToken: string,
album: AlbumSummary
) => ({
itemType: "album",
id: `album:${album.id}`,
title: album.name,
albumArtURI: defaultAlbumArtURI(webAddress, accessToken, album),
canPlay: true,
});
export const track = (
webAddress: string,
accessToken: string,
track: Track
) => ({
itemType: "track",
id: `track:${track.id}`,
mimeType: track.mimeType,
title: track.name,
trackMetadata: {
album: track.album.name,
albumId: track.album.id,
albumArtist: track.artist.name,
albumArtistId: track.artist.id,
albumArtURI: defaultAlbumArtURI(webAddress, accessToken, track.album),
artist: track.artist.name,
artistId: track.artist.id,
duration: track.duration,
genre: track.album.genre?.name,
genreId: track.album.genre?.id,
trackNumber: track.number,
},
});
export const artist = (
webAddress: string,
accessToken: string,
artist: ArtistSummary
) => ({
itemType: "artist",
id: `artist:${artist.id}`,
artistId: artist.id,
title: artist.name,
albumArtURI: defaultArtistArtURI(webAddress, accessToken, artist),
});
type SoapyHeaders = {
credentials?: Credentials;
};
function bindSmapiSoapServiceToExpress(
app: Express,
soapPath: string,
webAddress: string,
linkCodes: LinkCodes,
musicService: MusicService,
accessTokens: AccessTokens
) {
const sonosSoap = new SonosSoap(webAddress, linkCodes);
const soapyService = listen(
app,
soapPath,
{
Sonos: {
SonosSoap: {
getAppLink: () => sonosSoap.getAppLink(),
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
sonosSoap.getDeviceAuthToken({ linkCode }),
getMediaURI: async (
{ id }: { id: string },
_,
headers?: SoapyHeaders
) => {
if (!headers?.credentials) {
throw {
Fault: {
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
},
};
}
await musicService
.login(headers.credentials.loginToken.token)
.catch((_) => {
throw {
Fault: {
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
},
};
});
const [type, typeId] = id.split(":");
return {
getMediaURIResult: `${webAddress}/stream/${type}/${typeId}`,
httpHeaders: [
{
header: BONOB_ACCESS_TOKEN_HEADER,
value: accessTokens.mint(
headers?.credentials?.loginToken.token
),
},
],
};
},
getMediaMetadata: async (
{ id }: { id: string },
_,
headers?: SoapyHeaders
) => {
if (!headers?.credentials) {
throw {
Fault: {
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
},
};
}
const authToken = headers.credentials.loginToken.token;
const login = await musicService
.login(headers.credentials.loginToken.token)
.catch((_) => {
throw {
Fault: {
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
},
};
});
const typeId = id.split(":")[1];
const musicLibrary = login as MusicLibrary;
return musicLibrary.track(typeId!).then((it) => {
const accessToken = accessTokens.mint(authToken);
return {
getMediaMetadataResult: track(webAddress, accessToken, it),
};
});
},
getExtendedMetadata: async (
{
id,
index,
count,
}: // recursive,
{ id: string; index: number; count: number; recursive: boolean },
_,
headers?: SoapyHeaders
) => {
if (!headers?.credentials) {
throw {
Fault: {
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
},
};
}
const authToken = headers.credentials.loginToken.token;
const login = await musicService.login(authToken).catch((_) => {
throw {
Fault: {
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
},
};
});
const musicLibrary = login as MusicLibrary;
const [type, typeId] = id.split(":");
const paging = { _index: index, _count: count };
switch (type) {
case "artist":
return await musicLibrary.artist(typeId!).then((artist) => {
const [page, total] = slice2<Album>(paging)(artist.albums);
const accessToken = accessTokens.mint(authToken);
return {
getExtendedMetadataResult: {
count: page.length,
index: paging._index,
total,
mediaCollection: page.map((it) =>
album(webAddress, accessToken, it)
),
relatedBrowse:
artist.similarArtists.length > 0
? [
{
id: `relatedArtists:${artist.id}`,
type: "RELATED_ARTISTS",
},
]
: [],
},
};
});
default:
throw `Unsupported id:${id}`;
}
},
getMetadata: async (
{
id,
index,
count,
}: // recursive,
{ id: string; index: number; count: number; recursive: boolean },
_,
headers?: SoapyHeaders
) => {
if (!headers?.credentials) {
throw {
Fault: {
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
},
};
}
const authToken = headers.credentials.loginToken.token;
const login = await musicService.login(authToken).catch((_) => {
throw {
Fault: {
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
},
};
});
const musicLibrary = login as MusicLibrary;
const [type, typeId] = id.split(":");
const paging = { _index: index, _count: count };
logger.debug(`Fetching metadata type=${type}, typeId=${typeId}`);
switch (type) {
case "root":
return getMetadataResult({
mediaCollection: [
container({ id: "artists", title: "Artists" }),
container({ id: "albums", title: "Albums" }),
container({ id: "genres", title: "Genres" }),
],
index: 0,
total: 3,
});
case "artists":
return await musicLibrary.artists(paging).then((result) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult({
mediaCollection: result.results.map((it) =>
artist(webAddress, accessToken, it)
),
index: paging._index,
total: result.total,
});
});
case "albums":
return await musicLibrary.albums(paging).then((result) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult({
mediaCollection: result.results.map((it) =>
album(webAddress, accessToken, it)
),
index: paging._index,
total: result.total,
});
});
case "genres":
return await musicLibrary
.genres()
.then(slice2(paging))
.then(([page, total]) =>
getMetadataResult({
mediaCollection: page.map(genre),
index: paging._index,
total,
})
);
case "artist":
return await musicLibrary
.artist(typeId!)
.then((artist) => artist.albums)
.then(slice2(paging))
.then(([page, total]) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult({
mediaCollection: page.map((it) =>
album(webAddress, accessToken, it)
),
index: paging._index,
total,
});
});
case "relatedArtists":
return await musicLibrary
.artist(typeId!)
.then((artist) => artist.similarArtists)
.then(slice2(paging))
.then(([page, total]) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult({
mediaCollection: page.map((it) =>
artist(webAddress, accessToken, it)
),
index: paging._index,
total,
});
});
case "album":
return await musicLibrary
.tracks(typeId!)
.then(slice2(paging))
.then(([page, total]) => {
const accessToken = accessTokens.mint(authToken);
return getMetadataResult({
mediaMetadata: page.map((it) =>
track(webAddress, accessToken, it)
),
index: paging._index,
total,
});
});
default:
throw `Unsupported id:${id}`;
}
},
},
},
},
readFileSync(WSDL_FILE, "utf8")
);
soapyService.log = (type, data) => {
switch (type) {
case "info":
logger.info({ level: "info", data });
break;
case "warn":
logger.warn({ level: "warn", data });
break;
case "error":
logger.error({ level: "error", data });
break;
default:
logger.debug({ level: "debug", data });
}
};
}
export default bindSmapiSoapServiceToExpress;