Files
bonob/src/smapi.ts
2021-03-05 14:46:12 +11:00

299 lines
7.4 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,
ArtistSummary,
MusicLibrary,
MusicService,
} from "./music_service";
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";
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 GetMetadataResponse = {
getMetadataResult: {
count: number;
index: number;
total: number;
mediaCollection: MediaCollection[];
};
};
export function getMetadataResult({
mediaCollection,
index,
total,
}: {
mediaCollection: any[] | undefined;
index: number;
total: number;
}): GetMetadataResponse {
return {
getMetadataResult: {
count: mediaCollection?.length || 0,
index,
total,
mediaCollection: mediaCollection || [],
},
};
}
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;
};
export const container = ({
id,
title,
}: {
id: string;
title: string;
}): Container => ({
itemType: "container",
id,
title,
});
type SoapyHeaders = {
credentials?: Credentials;
};
function bindSmapiSoapServiceToExpress(
app: Express,
soapPath: string,
webAddress: string,
linkCodes: LinkCodes,
musicService: MusicService
) {
const sonosSoap = new SonosSoap(webAddress, linkCodes);
const soapyService = listen(
app,
soapPath,
{
Sonos: {
SonosSoap: {
getAppLink: () => sonosSoap.getAppLink(),
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
sonosSoap.getDeviceAuthToken({ linkCode }),
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 login = await musicService
.login(headers.credentials.loginToken.token)
.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 type=${type}, typeId=${typeId}`);
switch (type) {
case "root":
return getMetadataResult({
mediaCollection: [
{ itemType: "container", id: "artists", title: "Artists" },
{ itemType: "container", id: "albums", title: "Albums" },
],
index: 0,
total: 2,
});
case "artists":
return await musicLibrary
.artists(paging)
.then(
({
results,
total,
}: {
results: ArtistSummary[];
total: number;
}) =>
getMetadataResult({
mediaCollection: results.map((it) => ({
itemType: "artist",
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: it.image.small,
})),
index: paging._index,
total,
})
);
case "albums":
return await musicLibrary
.albums(paging)
.then(
({ results, total }: { results: Album[]; total: number }) =>
getMetadataResult({
mediaCollection: results.map((it) =>
container({
id: `album:${it.id}`,
title: it.name,
})
),
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;