diff --git a/package.json b/package.json index cb6f250..8bd0ab9 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "supertest": "^6.1.3", "ts-jest": "^26.4.4", "ts-mockito": "^2.6.1", - "ts-node": "^9.1.1" + "ts-node": "^9.1.1", + "xmldom-ts": "^0.3.1", + "xpath-ts": "^1.3.13" }, "scripts": { "build": "tsc", diff --git a/src/server.ts b/src/server.ts index 10e9da1..2b31589 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE, + SONOS_RECOMMENDED_IMAGE_SIZES, LOGIN_ROUTE, } from "./smapi"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; @@ -101,19 +102,29 @@ function server( res.type("application/xml").send(` - Linking sonos with bonob - string2 + Linking sonos with ${bonobService.name} - Linking sonos with bonob fr - string2 fr + Lier les sonos à la ${bonobService.name} `); }); app.get(PRESENTATION_MAP_ROUTE, (_, res) => { - res.send(""); + res.type("application/xml").send(` + + + + + ${SONOS_RECOMMENDED_IMAGE_SIZES.map( + (size) => + `` + )} + + + + `); }); app.get("/stream/track/:id", async (req, res) => { @@ -138,7 +149,7 @@ function server( } }); - app.get("/album/:albumId/art", (req, res) => { + app.get("/album/:albumId/art/size/:size", (req, res) => { const authToken = accessTokens.authTokenFor( req.query[BONOB_ACCESS_TOKEN_HEADER] as string ); @@ -147,7 +158,12 @@ function server( } else { return musicService .login(authToken) - .then((it) => it.coverArt(req.params["albumId"]!, 200)) + .then((it) => + it.coverArt( + req.params["albumId"]!, + Number.parseInt(req.params["size"]!) + ) + ) .then((coverArt) => { res.status(200); res.setHeader("content-type", coverArt.contentType); diff --git a/src/smapi.ts b/src/smapi.ts index b30e9de..8fb7cd5 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -20,6 +20,21 @@ 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"; +export const SONOS_RECOMMENDED_IMAGE_SIZES = [ + "60", + "80", + "120", + "180", + "192", + "200", + "230", + "300", + "600", + "640", + "750", + "1242", + "1500", +]; const WSDL_FILE = path.resolve( __dirname, @@ -203,7 +218,7 @@ const album = ( itemType: "album", id: `album:${album.id}`, title: album.name, - albumArtURI: `${webAddress}/album/${album.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`, + albumArtURI: `${webAddress}/album/${album.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`, }); const track = (track: Track) => ({ diff --git a/src/sonos.ts b/src/sonos.ts index da7ecc5..c8271d5 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -4,9 +4,11 @@ import { parse } from "node-html-parser"; import { MusicService } from "@svrooij/sonos/lib/services"; import { head } from "underscore"; import logger from "./logger"; -import STRINGS from "./strings"; import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi"; +export const STRINGS_VERSION = "2"; +export const PRESENTATION_MAP_VERSION = "7"; + export type Device = { name: string; group: string; @@ -40,11 +42,11 @@ export const bonobService = ( secureUri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`, strings: { uri: `${stripTailingSlash(bonobRoot)}${STRINGS_ROUTE}`, - version: STRINGS.version, + version: STRINGS_VERSION, }, presentation: { uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_ROUTE}`, - version: "1", + version: PRESENTATION_MAP_VERSION, }, pollInterval: 1200, authType, diff --git a/src/strings.ts b/src/strings.ts deleted file mode 100644 index 7d395b5..0000000 --- a/src/strings.ts +++ /dev/null @@ -1,10 +0,0 @@ - - -const STRINGS = { - version: "1", - values: { - "foo": "bar" - } -} - -export default STRINGS; \ No newline at end of file diff --git a/tests/server.test.ts b/tests/server.test.ts index cf59ef6..7496d84 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -299,7 +299,6 @@ describe("server", () => { expect(res.header["content-type"]).toEqual( stream.headers["content-type"] ); - // expect(res.header["content-length"]).toEqual(stream.headers["content-length"]); expect(res.header["accept-ranges"]).toEqual( stream.headers["accept-ranges"] ); @@ -396,7 +395,7 @@ describe("server", () => { }); }); - describe("/album/:albumId/art", () => { + describe("/album/:albumId/art/size", () => { const musicService = { login: jest.fn(), }; @@ -425,7 +424,7 @@ describe("server", () => { describe("when there is no access-token", () => { it("should return a 401", async () => { - const res = await request(server).get(`/album/123/art`); + const res = await request(server).get(`/album/123/art/size/180`); expect(res.status).toEqual(401); }); @@ -436,7 +435,7 @@ describe("server", () => { now = now.add(1, "day"); const res = await request(server).get( - `/album/123/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + `/album/123/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ); expect(res.status).toEqual(401); @@ -457,7 +456,7 @@ describe("server", () => { const res = await request(server) .get( - `/album/${albumId}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + `/album/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ) .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); @@ -465,7 +464,7 @@ describe("server", () => { expect(res.header["content-type"]).toEqual(coverArt.contentType); expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.coverArt).toHaveBeenCalledWith(albumId, 200); + expect(musicLibrary.coverArt).toHaveBeenCalledWith(albumId, 180); }); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index a060b1a..5bc901f 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -1,9 +1,11 @@ import crypto from "crypto"; import request from "supertest"; import { Client, createClientAsync } from "soap"; -import X2JS from "x2js"; import { v4 as uuid } from "uuid"; +import { DOMParserImpl } from "xmldom-ts"; +import * as xpath from "xpath-ts"; + import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes"; import makeServer, { BONOB_ACCESS_TOKEN_HEADER } from "../src/server"; import { bonobService, SONOS_DISABLED } from "../src/sonos"; @@ -12,6 +14,8 @@ import { LOGIN_ROUTE, getMetadataResult, getMetadataResult2, + PRESENTATION_MAP_ROUTE, + SONOS_RECOMMENDED_IMAGE_SIZES, } from "../src/smapi"; import { @@ -27,27 +31,62 @@ import supersoap from "./supersoap"; import { AuthSuccess } from "../src/music_service"; import { AccessTokens } from "../src/access_tokens"; -describe("service config", () => { - describe("strings.xml", () => { - const server = makeServer( - SONOS_DISABLED, - aService(), - "http://localhost:1234", - new InMemoryMusicService() - ); +const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); +describe("service config", () => { + const server = makeServer( + SONOS_DISABLED, + aService({ name: "music land" }), + "http://localhost:1234", + new InMemoryMusicService() + ); + + describe(STRINGS_ROUTE, () => { it("should return xml for the strings", async () => { const res = await request(server).get(STRINGS_ROUTE).send(); expect(res.status).toEqual(200); - const strings: any = new X2JS({ - arrayAccessFormPaths: ["stringtables", "stringtables.stringtable"], - }).xml2js(res.text); - - expect(strings.stringtables.stringtable[0].string[0]._stringId).toEqual( - "AppLinkMessage" + // removing the sonos xml ns as makes xpath queries with xpath-ts painful + const xml = parseXML( + res.text.replace('xmlns="http://sonos.com/sonosapi"', "") ); + + const sonosString = (id: string, lang: string) => + xpath.select( + `string(/stringtables/stringtable[@xml:lang="${lang}"]/string[@stringId="${id}"])`, + xml + ); + + expect(sonosString("AppLinkMessage", "en-US")).toEqual( + "Linking sonos with music land" + ); + expect(sonosString("AppLinkMessage", "fr-FR")).toEqual( + "Lier les sonos à la music land" + ); + }); + }); + + describe(PRESENTATION_MAP_ROUTE, () => { + it("should have an ArtWorkSizeMap for all sizes recommended by sonos", async () => { + const res = await request(server).get(PRESENTATION_MAP_ROUTE).send(); + + expect(res.status).toEqual(200); + + // removing the sonos xml ns as makes xpath queries with xpath-ts painful + const xml = parseXML( + res.text.replace('xmlns="http://sonos.com/sonosapi"', "") + ); + + const imageSizeMap = (size: string) => + xpath.select( + `string(/Presentation/PresentationMap[@type="ArtWorkSizeMap"]/Match/imageSizeMap/sizeEntry[@size="${size}"]/@substitution)`, + xml + ); + + SONOS_RECOMMENDED_IMAGE_SIZES.forEach((size) => { + expect(imageSizeMap(size)).toEqual(`/art/size/${size}`); + }); }); }); }); @@ -467,7 +506,11 @@ describe("api", () => { itemType: "album", id: `album:${it.id}`, title: it.name, - albumArtURI: `${rootUrl}/album/${it.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint(token.authToken)}`, + albumArtURI: `${rootUrl}/album/${ + it.id + }/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint( + token.authToken + )}`, })), index: 0, total: artistWithManyAlbums.albums.length, @@ -492,7 +535,11 @@ describe("api", () => { itemType: "album", id: `album:${it.id}`, title: it.name, - albumArtURI: `${rootUrl}/album/${it.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint(token.authToken)}`, + albumArtURI: `${rootUrl}/album/${ + it.id + }/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint( + token.authToken + )}`, })), index: 2, total: artistWithManyAlbums.albums.length, @@ -622,7 +669,11 @@ describe("api", () => { itemType: "album", id: `album:${it.id}`, title: it.name, - albumArtURI: `${rootUrl}/album/${it.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint(token.authToken)}`, + albumArtURI: `${rootUrl}/album/${ + it.id + }/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint( + token.authToken + )}`, })), index: 0, total: 6, @@ -648,7 +699,11 @@ describe("api", () => { itemType: "album", id: `album:${it.id}`, title: it.name, - albumArtURI: `${rootUrl}/album/${it.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint(token.authToken)}`, + albumArtURI: `${rootUrl}/album/${ + it.id + }/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint( + token.authToken + )}`, })), index: 2, total: 6, diff --git a/tests/sonos.test.ts b/tests/sonos.test.ts index 98879ed..8be66a4 100644 --- a/tests/sonos.test.ts +++ b/tests/sonos.test.ts @@ -19,6 +19,8 @@ import sonos, { asCustomdForm, bonobService, Service, + STRINGS_VERSION, + PRESENTATION_MAP_VERSION, } from "../src/sonos"; import { aSonosDevice, aService } from "./builders"; @@ -115,11 +117,11 @@ describe("sonos", () => { secureUri: `http://bonob.example.com/ws/sonos`, strings: { uri: `http://bonob.example.com/sonos/strings.xml`, - version: "1", + version: STRINGS_VERSION, }, presentation: { uri: `http://bonob.example.com/sonos/presentationMap.xml`, - version: "1", + version: PRESENTATION_MAP_VERSION, }, pollInterval: 1200, authType: "AppLink", @@ -138,11 +140,11 @@ describe("sonos", () => { secureUri: `http://bonob.example.com/ws/sonos`, strings: { uri: `http://bonob.example.com/sonos/strings.xml`, - version: "1", + version: STRINGS_VERSION, }, presentation: { uri: `http://bonob.example.com/sonos/presentationMap.xml`, - version: "1", + version: PRESENTATION_MAP_VERSION, }, pollInterval: 1200, authType: "AppLink", @@ -161,11 +163,11 @@ describe("sonos", () => { secureUri: `http://bonob.example.com/ws/sonos`, strings: { uri: `http://bonob.example.com/sonos/strings.xml`, - version: "1", + version: STRINGS_VERSION, }, presentation: { uri: `http://bonob.example.com/sonos/presentationMap.xml`, - version: "1", + version: PRESENTATION_MAP_VERSION, }, pollInterval: 1200, authType: "DeviceLink", diff --git a/yarn.lock b/yarn.lock index f07fa44..d1b6c08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5023,6 +5023,11 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xmldom-ts@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/xmldom-ts/-/xmldom-ts-0.3.1.tgz#a70df029e44e9af3c03ba22d88f174a953830091" + integrity sha512-dmEBAK3Msm+BPVZOiwhXCyM0/q3BeiI4eoAPj2Us1nDhsPPhePtZ5RkgEdngNQQFp3j6QFKMLHlBIRUxdpomcQ== + xmldom@0.1.27: version "0.1.27" resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9" @@ -5033,6 +5038,11 @@ xmldom@^0.1.19: resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff" integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ== +xpath-ts@^1.3.13: + version "1.3.13" + resolved "https://registry.yarnpkg.com/xpath-ts/-/xpath-ts-1.3.13.tgz#abca4f15dd7010161acf5b9cd01566f7b8d9541f" + integrity sha512-eNVXzDWbCV9KEB6fGNQ3qHFGC9PWBH7y2h13vZ+CMPNqOTZ+fgYTG4Sb0p5bVHiAwZrzgE6/tx987003P3dYpA== + xpath@0.0.27: version "0.0.27" resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.27.tgz#dd3421fbdcc5646ac32c48531b4d7e9d0c2cfa92"