mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
299 lines
7.4 KiB
TypeScript
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;
|