mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
254 lines
7.1 KiB
TypeScript
254 lines
7.1 KiB
TypeScript
import { SonosManager, SonosDevice } from "@svrooij/sonos";
|
|
import axios from "axios";
|
|
import { parse } from "node-html-parser";
|
|
import { MusicService } from "@svrooij/sonos/lib/services";
|
|
import { head } from "underscore";
|
|
import logger from "./logger";
|
|
import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi";
|
|
import qs from "querystring";
|
|
import { URLBuilder } from "./url_builder";
|
|
import { LANG } from "./i8n";
|
|
|
|
export const SONOS_LANG: LANG[] = [
|
|
"en-US",
|
|
"da-DK",
|
|
"de-DE",
|
|
"es-ES",
|
|
"fr-FR",
|
|
"it-IT",
|
|
"ja-JP",
|
|
"nb-NO",
|
|
"nl-NL",
|
|
"pt-BR",
|
|
"sv-SE",
|
|
"zh-CN",
|
|
];
|
|
|
|
export const PRESENTATION_AND_STRINGS_VERSION =
|
|
process.env["BNB_DEBUG"] === "true"
|
|
? `${Math.round(new Date().getTime() / 1000)}`
|
|
: "23";
|
|
|
|
// NOTE: manifest requires https for the URL, otherwise you will get an error trying to register
|
|
export type Capability =
|
|
| "search"
|
|
| "trFavorites" // Favorites: Adding/Removing Tracks (deprecated)
|
|
| "alFavorites" // Favorites: Adding/Removing Albums (deprecated)
|
|
| "ucPlaylists" // User Content Playlists
|
|
| "extendedMD" // Extended Metadata (More Menu, Info & Options)
|
|
| "contextHeaders"
|
|
| "authorizationHeader"
|
|
| "logging" // Playback duration logging at track end (deprecated)
|
|
| "manifest";
|
|
|
|
export const BONOB_CAPABILITIES: Capability[] = [
|
|
"search",
|
|
"ucPlaylists",
|
|
"extendedMD",
|
|
"logging",
|
|
];
|
|
|
|
export type Device = {
|
|
name: string;
|
|
group: string;
|
|
ip: string;
|
|
port: number;
|
|
};
|
|
|
|
export type Service = {
|
|
name: string;
|
|
sid: number;
|
|
uri: string;
|
|
secureUri: string;
|
|
strings?: { uri?: string; version?: string };
|
|
presentation?: { uri?: string; version?: string };
|
|
pollInterval?: number;
|
|
authType: "Anonymous" | "AppLink" | "DeviceLink" | "UserId";
|
|
};
|
|
|
|
export const stripTrailingSlash = (url: string) =>
|
|
url.endsWith("/") ? url.substring(0, url.length - 1) : url;
|
|
|
|
export const bonobService = (
|
|
name: string,
|
|
sid: number,
|
|
bonobUrl: URLBuilder,
|
|
authType: "Anonymous" | "AppLink" | "DeviceLink" | "UserId" = "AppLink"
|
|
): Service => ({
|
|
name,
|
|
sid,
|
|
uri: bonobUrl.append({ pathname: SOAP_PATH }).href(),
|
|
secureUri: bonobUrl.append({ pathname: SOAP_PATH }).href(),
|
|
strings: {
|
|
uri: bonobUrl.append({ pathname: STRINGS_ROUTE }).href(),
|
|
version: PRESENTATION_AND_STRINGS_VERSION,
|
|
},
|
|
presentation: {
|
|
uri: bonobUrl.append({ pathname: PRESENTATION_MAP_ROUTE }).href(),
|
|
version: PRESENTATION_AND_STRINGS_VERSION,
|
|
},
|
|
pollInterval: 1200,
|
|
authType,
|
|
});
|
|
|
|
export interface Sonos {
|
|
devices: () => Promise<Device[]>;
|
|
services: () => Promise<Service[]>;
|
|
remove: (sid: number) => Promise<boolean>;
|
|
register: (service: Service) => Promise<boolean>;
|
|
}
|
|
|
|
export const SONOS_DISABLED: Sonos = {
|
|
devices: () => Promise.resolve([]),
|
|
services: () => Promise.resolve([]),
|
|
remove: (_: number) => Promise.resolve(false),
|
|
register: (_: Service) => Promise.resolve(false),
|
|
};
|
|
|
|
export const asService = (musicService: MusicService): Service => ({
|
|
name: musicService.Name,
|
|
sid: musicService.Id,
|
|
uri: musicService.Uri,
|
|
secureUri: musicService.SecureUri,
|
|
strings: {
|
|
uri: musicService.Presentation?.Strings?.Uri,
|
|
version: musicService.Presentation?.Strings?.Version,
|
|
},
|
|
presentation: {
|
|
uri: musicService.Presentation?.PresentationMap?.Uri,
|
|
version: musicService.Presentation?.PresentationMap?.Version,
|
|
},
|
|
pollInterval: musicService.Policy.PollInterval,
|
|
authType: musicService.Policy.Auth,
|
|
});
|
|
|
|
export const asDevice = (sonosDevice: SonosDevice): Device => ({
|
|
name: sonosDevice.Name,
|
|
group: sonosDevice.GroupName || "",
|
|
ip: sonosDevice.Host,
|
|
port: sonosDevice.Port,
|
|
});
|
|
|
|
export const asRemoveCustomdForm = (csrfToken: string, sid: number) => ({
|
|
csrfToken,
|
|
sid: `${sid}`,
|
|
});
|
|
|
|
export const asCustomdForm = (csrfToken: string, service: Service) => ({
|
|
csrfToken,
|
|
sid: `${service.sid}`,
|
|
name: service.name,
|
|
uri: service.uri,
|
|
secureUri: service.secureUri,
|
|
pollInterval: `${service.pollInterval || 1200}`,
|
|
authType: service.authType,
|
|
stringsVersion: service.strings?.version || "0",
|
|
stringsUri: service.strings?.uri || "",
|
|
presentationMapVersion: service.presentation?.version || "0",
|
|
presentationMapUri: service.presentation?.uri || "",
|
|
containerType: "MService",
|
|
caps: BONOB_CAPABILITIES,
|
|
});
|
|
|
|
const setupDiscovery = (
|
|
manager: SonosManager,
|
|
sonosSeedHost?: string
|
|
): Promise<boolean> => {
|
|
if (sonosSeedHost == undefined || sonosSeedHost == "") {
|
|
logger.info("Trying to discover sonos devices");
|
|
return manager.InitializeWithDiscovery(10);
|
|
} else {
|
|
logger.info(`Trying to discover sonos devices using seed ${sonosSeedHost}`);
|
|
return manager.InitializeFromDevice(sonosSeedHost);
|
|
}
|
|
};
|
|
|
|
export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
|
|
const sonosDevices = async (): Promise<SonosDevice[]> => {
|
|
const manager = new SonosManager();
|
|
return setupDiscovery(manager, sonosSeedHost)
|
|
.then((success) => {
|
|
if (success) {
|
|
return manager.Devices;
|
|
} else {
|
|
logger.warn("Didn't find any sonos devices!");
|
|
return [];
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
logger.error(`Failed looking for sonos devices - ${e}`, { cause: e });
|
|
return [];
|
|
});
|
|
};
|
|
|
|
const post = async (
|
|
action: string,
|
|
customdForm: (csrfToken: string) => any
|
|
) => {
|
|
const anyDevice = await sonosDevices().then((devices) => head(devices));
|
|
|
|
if (!anyDevice) {
|
|
logger.warn("Failed to find a device to register with...");
|
|
return false;
|
|
}
|
|
|
|
logger.info(
|
|
`${action} using sonos device ${anyDevice.Name} @ ${anyDevice.Host}`
|
|
);
|
|
|
|
const customd = `http://${anyDevice.Host}:${anyDevice.Port}/customsd`;
|
|
|
|
const csrfToken = await axios.get(customd).then((response) =>
|
|
parse(response.data)
|
|
.querySelectorAll("input")
|
|
.find((it) => it.getAttribute("name") == "csrfToken")
|
|
?.getAttribute("value")
|
|
);
|
|
|
|
if (!csrfToken) {
|
|
logger.warn(
|
|
`Failed to find csrfToken at GET -> ${customd}, cannot ${action} service`
|
|
);
|
|
return false;
|
|
}
|
|
const form = customdForm(csrfToken);
|
|
logger.info(`${action} with sonos @ ${customd}`, { form });
|
|
return axios
|
|
.post(customd, new URLSearchParams(qs.stringify(form)), {
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
})
|
|
.then((response) => response.status == 200);
|
|
};
|
|
|
|
return {
|
|
devices: async () => sonosDevices().then((it) => it.map(asDevice)),
|
|
|
|
services: async () =>
|
|
sonosDevices()
|
|
.then((it) => head(it))
|
|
.then(
|
|
(device) =>
|
|
device?.MusicServicesService?.ListAndParseAvailableServices() || []
|
|
)
|
|
.then((it) => it.map(asService)),
|
|
|
|
remove: async (sid: number) =>
|
|
post("remove", (csrfToken) => asRemoveCustomdForm(csrfToken, sid)),
|
|
|
|
register: async (service: Service) =>
|
|
post("register", (csrfToken) => asCustomdForm(csrfToken, service)),
|
|
};
|
|
}
|
|
|
|
export type Discovery = {
|
|
enabled: boolean;
|
|
seedHost?: string;
|
|
};
|
|
|
|
export default (sonosDiscovery: Discovery = { enabled: true }): Sonos =>
|
|
sonosDiscovery.enabled
|
|
? autoDiscoverySonos(sonosDiscovery.seedHost)
|
|
: SONOS_DISABLED;
|