mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Ability to remove a bonob registration from sonos (#16)
This commit is contained in:
@@ -48,7 +48,7 @@
|
||||
"scripts": {
|
||||
"clean": "rm -Rf build",
|
||||
"build": "tsc",
|
||||
"dev": "BONOB_PORT=4000 BONOB_URL=http://$(hostname):4000 BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=false BONOB_SONOS_AUTO_REGISTER=false nodemon ./src/app.ts",
|
||||
"dev": "BONOB_PORT=4000 BONOB_URL=http://$(hostname):4000 BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true BONOB_SONOS_AUTO_REGISTER=false nodemon ./src/app.ts",
|
||||
"register-dev": "ts-node ./src/register.ts http://$(hostname):4000",
|
||||
"test": "jest"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
PRESENTATION_MAP_ROUTE,
|
||||
SONOS_RECOMMENDED_IMAGE_SIZES,
|
||||
LOGIN_ROUTE,
|
||||
REGISTER_ROUTE,
|
||||
CREATE_REGISTRATION_ROUTE,
|
||||
REMOVE_REGISTRATION_ROUTE
|
||||
} from "./smapi";
|
||||
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
||||
import { MusicService, isSuccess } from "./music_service";
|
||||
@@ -95,7 +96,8 @@ function server(
|
||||
services,
|
||||
bonobService: service,
|
||||
registeredBonobService,
|
||||
registerRoute: bonobUrl.append({ pathname: REGISTER_ROUTE }).pathname(),
|
||||
createRegistrationRoute: bonobUrl.append({ pathname: CREATE_REGISTRATION_ROUTE }).pathname(),
|
||||
removeRegistrationRoute: bonobUrl.append({ pathname: REMOVE_REGISTRATION_ROUTE }).pathname(),
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -110,7 +112,7 @@ function server(
|
||||
});
|
||||
});
|
||||
|
||||
app.post(REGISTER_ROUTE, (_, res) => {
|
||||
app.post(CREATE_REGISTRATION_ROUTE, (_, res) => {
|
||||
sonos.register(service).then((success) => {
|
||||
if (success) {
|
||||
res.render("success", {
|
||||
@@ -124,6 +126,20 @@ function server(
|
||||
});
|
||||
});
|
||||
|
||||
app.post(REMOVE_REGISTRATION_ROUTE, (_, res) => {
|
||||
sonos.remove(service.sid).then((success) => {
|
||||
if (success) {
|
||||
res.render("success", {
|
||||
message: `Successfully removed registration`,
|
||||
});
|
||||
} else {
|
||||
res.status(500).render("failure", {
|
||||
message: `Failed to remove registration!`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get(LOGIN_ROUTE, (req, res) => {
|
||||
res.render("login", {
|
||||
bonobService: service,
|
||||
|
||||
@@ -23,7 +23,8 @@ import { Clock } from "./clock";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
|
||||
export const LOGIN_ROUTE = "/login";
|
||||
export const REGISTER_ROUTE = "/register";
|
||||
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
|
||||
export const REMOVE_REGISTRATION_ROUTE = "/registration/remove";
|
||||
export const SOAP_PATH = "/ws/sonos";
|
||||
export const STRINGS_ROUTE = "/sonos/strings.xml";
|
||||
export const PRESENTATION_MAP_ROUTE = "/sonos/presentationMap.xml";
|
||||
|
||||
57
src/sonos.ts
57
src/sonos.ts
@@ -61,14 +61,14 @@ export const bonobService = (
|
||||
): Service => ({
|
||||
name,
|
||||
sid,
|
||||
uri: bonobUrl.append({pathname: SOAP_PATH }).href(),
|
||||
secureUri: bonobUrl.append({pathname: SOAP_PATH }).href(),
|
||||
uri: bonobUrl.append({ pathname: SOAP_PATH }).href(),
|
||||
secureUri: bonobUrl.append({ pathname: SOAP_PATH }).href(),
|
||||
strings: {
|
||||
uri: bonobUrl.append({pathname: STRINGS_ROUTE }).href(),
|
||||
uri: bonobUrl.append({ pathname: STRINGS_ROUTE }).href(),
|
||||
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||
},
|
||||
presentation: {
|
||||
uri: bonobUrl.append({pathname: PRESENTATION_MAP_ROUTE }).href(),
|
||||
uri: bonobUrl.append({ pathname: PRESENTATION_MAP_ROUTE }).href(),
|
||||
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||
},
|
||||
pollInterval: 1200,
|
||||
@@ -78,12 +78,14 @@ export const bonobService = (
|
||||
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(true),
|
||||
register: (_: Service) => Promise.resolve(true),
|
||||
};
|
||||
|
||||
@@ -111,6 +113,11 @@ export const asDevice = (sonosDevice: SonosDevice): Device => ({
|
||||
port: sonosDevice.Port,
|
||||
});
|
||||
|
||||
export const asRemoveCustomdForm = (csrfToken: string, sid: number) => ({
|
||||
csrfToken,
|
||||
sid: `${sid}`
|
||||
});
|
||||
|
||||
export const asCustomdForm = (csrfToken: string, service: Service) => ({
|
||||
csrfToken,
|
||||
sid: `${service.sid}`,
|
||||
@@ -158,19 +165,7 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
|
||||
});
|
||||
};
|
||||
|
||||
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)),
|
||||
|
||||
register: async (service: Service) => {
|
||||
const post = async (action: string, customdForm: (csrfToken: string) => any) => {
|
||||
const anyDevice = await sonosDevices().then((devices) => head(devices));
|
||||
|
||||
if (!anyDevice) {
|
||||
@@ -179,7 +174,7 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Registering ${service.name}(SID:${service.sid}) with sonos device ${anyDevice.Name} @ ${anyDevice.Host}`
|
||||
`${action} using sonos device ${anyDevice.Name} @ ${anyDevice.Host}`
|
||||
);
|
||||
|
||||
const customd = `http://${anyDevice.Host}:${anyDevice.Port}/customsd`;
|
||||
@@ -193,20 +188,36 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
|
||||
|
||||
if (!csrfToken) {
|
||||
logger.warn(
|
||||
`Failed to find csrfToken at GET -> ${customd}, cannot register service`
|
||||
`Failed to find csrfToken at GET -> ${customd}, cannot ${action} service`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const customdForm = asCustomdForm(csrfToken, service);
|
||||
logger.info(`Registering with sonos @ ${customd}`, { customdForm });
|
||||
const form = customdForm(csrfToken)
|
||||
logger.info(`${action} with sonos @ ${customd}`, { form });
|
||||
return axios
|
||||
.post(customd, new URLSearchParams(qs.stringify(customdForm)), {
|
||||
.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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ describe("server", () => {
|
||||
devices: () => Promise.resolve([device1, device2]),
|
||||
services: () =>
|
||||
Promise.resolve([service1, service2, service3, service4]),
|
||||
remove: () => Promise.resolve(false),
|
||||
register: () => Promise.resolve(false),
|
||||
};
|
||||
|
||||
@@ -285,6 +286,7 @@ describe("server", () => {
|
||||
const fakeSonos: Sonos = {
|
||||
devices: () => Promise.resolve([]),
|
||||
services: () => Promise.resolve([service1, service2, bonobService]),
|
||||
remove: () => Promise.resolve(false),
|
||||
register: () => Promise.resolve(false),
|
||||
};
|
||||
|
||||
@@ -340,6 +342,7 @@ describe("server", () => {
|
||||
describe("/register", () => {
|
||||
const sonos = {
|
||||
register: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
const theService = aService({
|
||||
name: "We can all live a life of service",
|
||||
@@ -352,12 +355,13 @@ describe("server", () => {
|
||||
new InMemoryMusicService()
|
||||
);
|
||||
|
||||
describe("registering", () => {
|
||||
describe("when is successful", () => {
|
||||
it("should return a nice message", async () => {
|
||||
sonos.register.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.post(bonobUrl.append({ pathname: "/register" }).path())
|
||||
.post(bonobUrl.append({ pathname: "/registration/add" }).path())
|
||||
.send();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
@@ -373,7 +377,7 @@ describe("server", () => {
|
||||
sonos.register.mockResolvedValue(false);
|
||||
|
||||
const res = await request(server)
|
||||
.post(bonobUrl.append({ pathname: "/register" }).path())
|
||||
.post(bonobUrl.append({ pathname: "/registration/add" }).path())
|
||||
.send();
|
||||
|
||||
expect(res.status).toEqual(500);
|
||||
@@ -385,6 +389,41 @@ describe("server", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("removing a registration", () => {
|
||||
describe("when is successful", () => {
|
||||
it("should return a nice message", async () => {
|
||||
sonos.remove.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.post(bonobUrl.append({ pathname: "/registration/remove" }).path())
|
||||
.send();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.text).toMatch("Successfully removed registration");
|
||||
|
||||
expect(sonos.remove.mock.calls.length).toEqual(1);
|
||||
expect(sonos.remove.mock.calls[0][0]).toBe(theService.sid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when is unsuccessful", () => {
|
||||
it("should return a failure message", async () => {
|
||||
sonos.remove.mockResolvedValue(false);
|
||||
|
||||
const res = await request(server)
|
||||
.post(bonobUrl.append({ pathname: "/registration/remove" }).path())
|
||||
.send();
|
||||
|
||||
expect(res.status).toEqual(500);
|
||||
expect(res.text).toMatch("Failed to remove registration!");
|
||||
|
||||
expect(sonos.remove.mock.calls.length).toEqual(1);
|
||||
expect(sonos.remove.mock.calls[0][0]).toBe(theService.sid);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("/stream", () => {
|
||||
const musicService = {
|
||||
login: jest.fn(),
|
||||
|
||||
@@ -22,6 +22,7 @@ import sonos, {
|
||||
Service,
|
||||
PRESENTATION_AND_STRINGS_VERSION,
|
||||
BONOB_CAPABILITIES,
|
||||
asRemoveCustomdForm,
|
||||
} from "../src/sonos";
|
||||
|
||||
import { aSonosDevice, aService } from "./builders";
|
||||
@@ -259,6 +260,18 @@ describe("sonos", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("asRemoveCustomdForm", () => {
|
||||
it("should return a form", () => {
|
||||
const csrfToken = uuid();
|
||||
const sid = 123;
|
||||
|
||||
expect(asRemoveCustomdForm(csrfToken, sid)).toEqual({
|
||||
csrfToken,
|
||||
sid: "123"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when is disabled", () => {
|
||||
it("should return a disabled client", async () => {
|
||||
const disabled = sonos(false);
|
||||
@@ -684,4 +697,170 @@ describe("sonos", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("removing a service", () => {
|
||||
const device1 = aSonosDevice({
|
||||
Name: "d1",
|
||||
Host: "127.0.0.11",
|
||||
Port: 111,
|
||||
});
|
||||
|
||||
const device2 = aSonosDevice({
|
||||
Name: "d2",
|
||||
});
|
||||
|
||||
const POST_CONFIG = {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
};
|
||||
|
||||
const sidToRemove = 333;
|
||||
|
||||
const mockGet = jest.fn();
|
||||
const mockPost = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockGet.mockClear();
|
||||
mockPost.mockClear();
|
||||
|
||||
axios.get = mockGet;
|
||||
axios.post = mockPost;
|
||||
});
|
||||
|
||||
describe("when successful", () => {
|
||||
it("should post the remove into the first found sonos device, returning true", async () => {
|
||||
const sonosManager = {
|
||||
InitializeWithDiscovery: jest.fn(),
|
||||
Devices: [device1, device2],
|
||||
};
|
||||
|
||||
mockSonosManagerConstructor.mockReturnValue(
|
||||
(sonosManager as unknown) as SonosManager
|
||||
);
|
||||
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
|
||||
|
||||
const csrfToken = `csrfToken-${uuid()}`;
|
||||
|
||||
mockGet.mockResolvedValue({
|
||||
status: 200,
|
||||
data: `<html><input name='csrfToken' value='${csrfToken}'></html>`,
|
||||
});
|
||||
mockPost.mockResolvedValue({ status: 200, data: "" });
|
||||
|
||||
const result = await sonos().remove(sidToRemove);
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
`http://${device1.Host}:${device1.Port}/customsd`
|
||||
);
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
`http://${device1.Host}:${device1.Port}/customsd`,
|
||||
new URLSearchParams(qs.stringify(asRemoveCustomdForm(csrfToken, sidToRemove))),
|
||||
POST_CONFIG
|
||||
);
|
||||
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when cannot find any devices", () => {
|
||||
it("should return false", async () => {
|
||||
const sonosManager = {
|
||||
InitializeWithDiscovery: jest.fn(),
|
||||
Devices: [],
|
||||
};
|
||||
|
||||
mockSonosManagerConstructor.mockReturnValue(
|
||||
(sonosManager as unknown) as SonosManager
|
||||
);
|
||||
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
|
||||
|
||||
const result = await sonos().remove(sidToRemove);
|
||||
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when cannot get csrfToken", () => {
|
||||
describe("when the token is missing", () => {
|
||||
it("should return false", async () => {
|
||||
const sonosManager = {
|
||||
InitializeWithDiscovery: jest.fn(),
|
||||
Devices: [device1, device2],
|
||||
};
|
||||
|
||||
mockSonosManagerConstructor.mockReturnValue(
|
||||
(sonosManager as unknown) as SonosManager
|
||||
);
|
||||
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
|
||||
|
||||
mockGet.mockResolvedValue({
|
||||
status: 200,
|
||||
data: `<html></html>`,
|
||||
});
|
||||
|
||||
const result = await sonos().remove(sidToRemove);
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the token call returns a non 200", () => {
|
||||
it("should return false", async () => {
|
||||
const sonosManager = {
|
||||
InitializeWithDiscovery: jest.fn(),
|
||||
Devices: [device1, device2],
|
||||
};
|
||||
|
||||
mockSonosManagerConstructor.mockReturnValue(
|
||||
(sonosManager as unknown) as SonosManager
|
||||
);
|
||||
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
|
||||
|
||||
mockGet.mockResolvedValue({
|
||||
status: 400,
|
||||
data: `<html></html>`,
|
||||
});
|
||||
|
||||
const result = await sonos().remove(sidToRemove);
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when posting in the sid to delete fails", () => {
|
||||
it("should return false", async () => {
|
||||
const sonosManager = {
|
||||
InitializeWithDiscovery: jest.fn(),
|
||||
Devices: [device1, device2],
|
||||
};
|
||||
|
||||
mockSonosManagerConstructor.mockReturnValue(
|
||||
(sonosManager as unknown) as SonosManager
|
||||
);
|
||||
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
|
||||
|
||||
const csrfToken = `csrfToken-${uuid()}`;
|
||||
|
||||
mockGet.mockResolvedValue({
|
||||
status: 200,
|
||||
data: `<html><input name='csrfToken' value='${csrfToken}'></html>`,
|
||||
});
|
||||
mockPost.mockResolvedValue({ status: 500, data: "" });
|
||||
|
||||
const result = await sonos().remove(sidToRemove);
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
<% } else { %>
|
||||
<h3>No existing service registration</h3>
|
||||
<% } %>
|
||||
<form action="<%= it.registerRoute %>" method="POST"><button>Re-register</button></form>
|
||||
<form action="<%= it.createRegistrationRoute %>" method="POST"><button>Register</button></form>
|
||||
<form action="<%= it.removeRegistrationRoute %>" method="POST"><button>Remove Registration</button></form>
|
||||
<h2>Devices</h2>
|
||||
<ul>
|
||||
<% it.devices.forEach(function(d){ %>
|
||||
|
||||
Reference in New Issue
Block a user