From be4fcdff2435769363d990ebe2e86e335fa53e94 Mon Sep 17 00:00:00 2001 From: Simon J Date: Sun, 12 Sep 2021 15:34:09 +1000 Subject: [PATCH] Support for register using a seed host (#51) --- README.md | 12 +++++ src/app.ts | 4 +- src/config.ts | 8 +-- src/register.ts | 6 ++- src/registrar.ts | 49 +++++++++++------ src/sonos.ts | 46 +++++++++++----- tests/config.test.ts | 6 +-- tests/navidrome.test.ts | 4 +- tests/registrar.test.ts | 116 ++++++++++++++++++++++++++++++++++++++++ tests/sonos.test.ts | 12 ++--- 10 files changed, 219 insertions(+), 44 deletions(-) create mode 100644 tests/registrar.test.ts diff --git a/README.md b/README.md index 9a29de5..a199327 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ docker run \ Now within the LAN that contains the sonos devices run bonob the registration process. +#### Using auto-discovery + ```bash docker run \ --rm \ @@ -86,6 +88,16 @@ docker run \ simojenki/bonob register https://my-server.example.com/bonob ``` +#### Using a seed host + +```bash +docker run \ + --rm \ + -e BONOB_SONOS_SEED_HOST=192.168.1.163 \ + simojenki/bonob register https://my-server.example.com/bonob +``` + + ### Running bonob and navidrome using docker-compose ```yaml diff --git a/src/app.ts b/src/app.ts index 8252b92..6a25903 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,7 +22,7 @@ const bonob = bonobService( "AppLink" ); -const sonosSystem = sonos(config.sonos.deviceDiscovery, config.sonos.seedHost); +const sonosSystem = sonos(config.sonos.discovery); const streamUserAgent = config.navidrome.customClientsFor ? appendMimeTypeToClientFor(config.navidrome.customClientsFor.split(",")) @@ -90,7 +90,7 @@ if (config.sonos.autoRegister) { ); } }); -} else if(config.sonos.deviceDiscovery) { +} else if(config.sonos.discovery.auto) { sonosSystem.devices().then(devices => { devices.forEach(d => { logger.info(`Found device ${d.name}(${d.group}) @ ${d.ip}:${d.port}`) diff --git a/src/config.ts b/src/config.ts index 5950d0d..d302495 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,9 +36,11 @@ export default function () { }, sonos: { serviceName: process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob", - deviceDiscovery: - (process.env["BONOB_SONOS_DEVICE_DISCOVERY"] || "true") == "true", - seedHost: process.env["BONOB_SONOS_SEED_HOST"], + discovery: { + auto: + (process.env["BONOB_SONOS_DEVICE_DISCOVERY"] || "true") == "true", + seedHost: process.env["BONOB_SONOS_SEED_HOST"], + }, autoRegister: (process.env["BONOB_SONOS_AUTO_REGISTER"] || "false") == "true", sid: Number(process.env["BONOB_SONOS_SERVICE_ID"] || "246"), diff --git a/src/register.ts b/src/register.ts index f6fd9c4..615dd7c 100644 --- a/src/register.ts +++ b/src/register.ts @@ -1,4 +1,5 @@ import registrar from "./registrar"; +import readConfig from "./config"; import { URLBuilder } from "./url_builder"; const params = process.argv.slice(2); @@ -9,7 +10,10 @@ if (params.length != 1) { } const bonobUrl = new URLBuilder(params[0]!); -registrar(bonobUrl)() + +const config = readConfig(); + +registrar(bonobUrl, config.sonos.discovery)() .then((success) => { if (success) { console.log(`Successfully registered bonob @ ${bonobUrl} with sonos`); diff --git a/src/registrar.ts b/src/registrar.ts index 18a5e94..392b354 100644 --- a/src/registrar.ts +++ b/src/registrar.ts @@ -1,19 +1,38 @@ import axios from "axios"; +import _ from "underscore"; import logger from "./logger"; -import sonos, { bonobService } from "./sonos"; +import sonos, { bonobService, Discovery } from "./sonos"; import { URLBuilder } from "./url_builder"; -export default (bonobUrl: URLBuilder) => async () => { - const about = bonobUrl.append({ pathname: "/about" }); - logger.info(`Fetching bonob service about from ${about}`); - return axios - .get(about.href()) - .then((res) => { - if (res.status == 200) return res.data; - else throw `Unexpected response status ${res.status} from ${about}`; - }) - .then((about) => - bonobService(about.service.name, about.service.sid, bonobUrl) - ) - .then((bonobService) => sonos(true).register(bonobService)); -}; +export default ( + bonobUrl: URLBuilder, + sonosDiscovery: Discovery = { + auto: true, + seedHost: undefined, + } + ) => + async () => { + const about = bonobUrl.append({ pathname: "/about" }); + logger.info(`Fetching bonob service about from ${about}`); + return axios + .get(about.href()) + .then((res) => { + if (res.status == 200) return res.data; + else throw `Unexpected response status ${res.status} from ${about}`; + }) + .then((res) => { + const name = _.get(res, ["service", "name"]); + const sid = _.get(res, ["service", "sid"]); + if (!name || !sid) { + throw `Unexpected response from ${about.href()}, expected service.name and service.sid`; + } + return { + name, + sid: Number.parseInt(sid), + }; + }) + .then(({ name, sid }: { name: string; sid: number }) => + bonobService(name, sid, bonobUrl) + ) + .then((service) => sonos(sonosDiscovery).register(service)); + }; diff --git a/src/sonos.ts b/src/sonos.ts index e13e765..f85a9b9 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -9,7 +9,20 @@ 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 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 = "21"; @@ -118,7 +131,7 @@ export const asDevice = (sonosDevice: SonosDevice): Device => ({ export const asRemoveCustomdForm = (csrfToken: string, sid: number) => ({ csrfToken, - sid: `${sid}` + sid: `${sid}`, }); export const asCustomdForm = (csrfToken: string, service: Service) => ({ @@ -168,7 +181,10 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos { }); }; - const post = async (action: string, customdForm: (csrfToken: string) => any) => { + const post = async ( + action: string, + customdForm: (csrfToken: string) => any + ) => { const anyDevice = await sonosDevices().then((devices) => head(devices)); if (!anyDevice) { @@ -195,7 +211,7 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos { ); return false; } - const form = customdForm(csrfToken) + const form = customdForm(csrfToken); logger.info(`${action} with sonos @ ${customd}`, { form }); return axios .post(customd, new URLSearchParams(qs.stringify(form)), { @@ -218,16 +234,22 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos { ) .then((it) => it.map(asService)), - remove: async (sid: number) => post("remove", (csrfToken) => asRemoveCustomdForm(csrfToken, sid)), + remove: async (sid: number) => + post("remove", (csrfToken) => asRemoveCustomdForm(csrfToken, sid)), - register: async (service: Service) => post("register", (csrfToken) => asCustomdForm(csrfToken, service)), + register: async (service: Service) => + post("register", (csrfToken) => asCustomdForm(csrfToken, service)), }; } -const sonos = ( - discoveryEnabled: boolean = true, - sonosSeedHost: string | undefined = undefined -): Sonos => - discoveryEnabled ? autoDiscoverySonos(sonosSeedHost) : SONOS_DISABLED; +export type Discovery = { + auto: boolean; + seedHost?: string; +}; -export default sonos; +export default ( + sonosDiscovery: Discovery = { auto: true } +): Sonos => + sonosDiscovery.auto + ? autoDiscoverySonos(sonosDiscovery.seedHost) + : SONOS_DISABLED; diff --git a/tests/config.test.ts b/tests/config.test.ts index 037c871..29d0232 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -198,17 +198,17 @@ describe("config", () => { "deviceDiscovery", "BONOB_SONOS_DEVICE_DISCOVERY", true, - (config) => config.sonos.deviceDiscovery + (config) => config.sonos.discovery.auto ); describe("seedHost", () => { it("should default to undefined", () => { - expect(config().sonos.seedHost).toBeUndefined(); + expect(config().sonos.discovery.seedHost).toBeUndefined(); }); it("should be overridable", () => { process.env["BONOB_SONOS_SEED_HOST"] = "123.456.789.0"; - expect(config().sonos.seedHost).toEqual("123.456.789.0"); + expect(config().sonos.discovery.seedHost).toEqual("123.456.789.0"); }); }); diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index 49f662a..e667b79 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -20,6 +20,8 @@ import sharp from "sharp"; jest.mock("sharp"); import randomString from "../src/random_string"; +jest.mock("../src/random_string"); + import { Album, Artist, @@ -43,8 +45,6 @@ import { aTrack, } from "./builders"; -jest.mock("../src/random_string"); - describe("t", () => { it("should be an md5 of the password and the salt", () => { const p = "password123"; diff --git a/tests/registrar.test.ts b/tests/registrar.test.ts new file mode 100644 index 0000000..69b66e5 --- /dev/null +++ b/tests/registrar.test.ts @@ -0,0 +1,116 @@ +import axios from "axios"; +jest.mock("axios"); + +const fakeSonos = { + register: jest.fn(), +}; + +import sonos, { bonobService } from "../src/sonos"; +jest.mock("../src/sonos"); + +import registrar from "../src/registrar"; +import { URLBuilder } from "../src/url_builder"; + +describe("registrar", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + describe("when the bonob service can not be found", () => { + it("should fail", async () => { + const status = 409; + + (axios.get as jest.Mock).mockResolvedValue({ + status, + }); + + const bonobUrl = new URLBuilder("http://fail.example.com/bonob"); + + return expect(registrar(bonobUrl)()).rejects.toEqual( + `Unexpected response status ${status} from ${bonobUrl + .append({ pathname: "/about" }) + .href()}` + ); + }); + }); + + describe("when the bonob service returns unexpected content", () => { + it("should fail", async () => { + (axios.get as jest.Mock).mockResolvedValue({ + status: 200, + // invalid response from /about as does not have name and sid + data: {} + }); + + const bonobUrl = new URLBuilder("http://fail.example.com/bonob"); + + return expect(registrar(bonobUrl)()).rejects.toEqual( + `Unexpected response from ${bonobUrl + .append({ pathname: "/about" }) + .href()}, expected service.name and service.sid` + ); + }); + }); + + describe("when the bonob service can be found", () => { + const bonobUrl = new URLBuilder("http://success.example.com/bonob"); + + const serviceDetails = { + name: "bob", + sid: 123, + }; + + const service = "service"; + + beforeEach(() => { + (axios.get as jest.Mock).mockResolvedValue({ + status: 200, + data: { + service: serviceDetails, + }, + }); + + (bonobService as jest.Mock).mockResolvedValue(service); + (sonos as jest.Mock).mockReturnValue(fakeSonos); + }); + + describe("when registration succeeds", () => { + it("should fetch the service details and register", async () => { + fakeSonos.register.mockResolvedValue(true); + const sonosDiscovery = { auto: true }; + + expect(await registrar(bonobUrl, sonosDiscovery)()).toEqual( + true + ); + + expect(bonobService).toHaveBeenCalledWith( + serviceDetails.name, + serviceDetails.sid, + bonobUrl + ); + expect(sonos).toHaveBeenCalledWith(sonosDiscovery); + expect(fakeSonos.register).toHaveBeenCalledWith(service); + }); + }); + + describe("when registration fails", () => { + it("should fetch the service details and register", async () => { + fakeSonos.register.mockResolvedValue(false); + const sonosDiscovery = { auto: false, seedHost: "192.168.1.163" }; + + expect(await registrar(bonobUrl, sonosDiscovery)()).toEqual( + false + ); + + expect(bonobService).toHaveBeenCalledWith( + serviceDetails.name, + serviceDetails.sid, + bonobUrl + ); + expect(sonos).toHaveBeenCalledWith(sonosDiscovery); + expect(fakeSonos.register).toHaveBeenCalledWith(service); + }); + }); + }); +}); diff --git a/tests/sonos.test.ts b/tests/sonos.test.ts index bfff457..bad0d5a 100644 --- a/tests/sonos.test.ts +++ b/tests/sonos.test.ts @@ -274,7 +274,7 @@ describe("sonos", () => { describe("when is disabled", () => { it("should return a disabled client", async () => { - const disabled = sonos(false); + const disabled = sonos({ auto: false }); expect(disabled).toEqual(SONOS_DISABLED); expect(await disabled.devices()).toEqual([]); @@ -310,7 +310,7 @@ describe("sonos", () => { ); sonosManager.InitializeWithDiscovery.mockResolvedValue(true); - const actualDevices = await sonos(true, undefined).devices(); + const actualDevices = await sonos({ auto: true }).devices(); expect(SonosManager).toHaveBeenCalledTimes(1); expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10); @@ -331,7 +331,7 @@ describe("sonos", () => { ); sonosManager.InitializeWithDiscovery.mockResolvedValue(true); - const actualDevices = await sonos(true, "").devices(); + const actualDevices = await sonos({ auto: true, seedHost: "" }).devices(); expect(SonosManager).toHaveBeenCalledTimes(1); expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10); @@ -354,7 +354,7 @@ describe("sonos", () => { ); sonosManager.InitializeFromDevice.mockResolvedValue(true); - const actualDevices = await sonos(true, seedHost).devices(); + const actualDevices = await sonos({ auto: true, seedHost }).devices(); expect(SonosManager).toHaveBeenCalledTimes(1); expect(sonosManager.InitializeFromDevice).toHaveBeenCalledWith( @@ -377,7 +377,7 @@ describe("sonos", () => { ); sonosManager.InitializeWithDiscovery.mockResolvedValue(true); - const actualDevices = await sonos(true, undefined).devices(); + const actualDevices = await sonos({ auto: true, seedHost: undefined }).devices(); expect(actualDevices).toEqual([ { @@ -408,7 +408,7 @@ describe("sonos", () => { ); sonosManager.InitializeWithDiscovery.mockResolvedValue(false); - expect(await sonos(true, "").devices()).toEqual([]); + expect(await sonos({ auto: true, seedHost: "" }).devices()).toEqual([]); }); }); });