diff --git a/src/smapi.ts b/src/smapi.ts index b381ee6..07b4041 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -7,6 +7,7 @@ import logger from "./logger"; import { LinkCodes } from "./link_codes"; import { + Album, AlbumSummary, ArtistSummary, MusicLibrary, @@ -77,52 +78,30 @@ export type MediaCollection = { title: string; }; -export type GetMetadataResponse = { - getMetadataResult: { - count: number; - index: number; - total: number; - mediaCollection: any[] | undefined; - mediaMetadata: any[] | undefined; - }; +export type getMetadataResult = { + count: number; + index: number; + total: number; + mediaCollection?: any[]; + mediaMetadata?: any[]; }; -export function getMetadataResult({ - mediaCollection, - index, - total, -}: { - mediaCollection: any[] | undefined; - index: number; - total: number; -}): GetMetadataResponse { - return { - getMetadataResult: { - count: mediaCollection?.length || 0, - index, - total, - mediaCollection: mediaCollection || [], - mediaMetadata: undefined, - }, - }; -} +export type GetMetadataResponse = { + getMetadataResult: getMetadataResult; +}; -export function getMetadataResult2({ - mediaMetadata, - index, - total, -}: { - mediaMetadata: any[] | undefined; - index: number; - total: number; -}): GetMetadataResponse { +export function getMetadataResult( + result: Partial +): GetMetadataResponse { + const count = + (result?.mediaCollection?.length || 0) + + (result?.mediaMetadata?.length || 0); return { getMetadataResult: { - count: mediaMetadata?.length || 0, - index, - total, - mediaCollection: undefined, - mediaMetadata: mediaMetadata || [], + count, + index: 0, + total: count, + ...result, }, }; } @@ -217,7 +196,7 @@ export const defaultAlbumArtURI = ( album: AlbumSummary ) => `${webAddress}/album/${album.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`; - + export const defaultArtistArtURI = ( webAddress: string, accessToken: string, @@ -225,7 +204,7 @@ export const defaultArtistArtURI = ( ) => `${webAddress}/artist/${artist.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`; -const album = ( +export const album = ( webAddress: string, accessToken: string, album: AlbumSummary @@ -234,7 +213,7 @@ const album = ( id: `album:${album.id}`, title: album.name, albumArtURI: defaultAlbumArtURI(webAddress, accessToken, album), - canPlay: true + canPlay: true, }); export const track = ( @@ -262,6 +241,18 @@ export const track = ( }, }); +export const artist = ( + webAddress: string, + accessToken: string, + artist: ArtistSummary +) => ({ + itemType: "artist", + id: `artist:${artist.id}`, + artistId: artist.id, + title: artist.name, + albumArtURI: defaultArtistArtURI(webAddress, accessToken, artist), +}); + type SoapyHeaders = { credentials?: Credentials; }; @@ -355,6 +346,68 @@ function bindSmapiSoapServiceToExpress( }; }); }, + getExtendedMetadata: 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 authToken = headers.credentials.loginToken.token; + const login = await musicService.login(authToken).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 }; + switch (type) { + case "artist": + return await musicLibrary.artist(typeId!).then((artist) => { + const [page, total] = slice2(paging)(artist.albums); + const accessToken = accessTokens.mint(authToken); + + return { + getExtendedMetadataResult: { + count: page.length, + index: paging._index, + total, + mediaCollection: page.map((it) => + album(webAddress, accessToken, it) + ), + relatedBrowse: + artist.similarArtists.length > 0 + ? [ + { + id: `relatedArtists:${artist.id}`, + type: "RELATED_ARTISTS", + }, + ] + : [], + }, + }; + }); + default: + throw `Unsupported id:${id}`; + } + }, getMetadata: async ( { id, @@ -387,7 +440,7 @@ function bindSmapiSoapServiceToExpress( const [type, typeId] = id.split(":"); const paging = { _index: index, _count: count }; - logger.debug(`Fetching type=${type}, typeId=${typeId}`); + logger.debug(`Fetching metadata type=${type}, typeId=${typeId}`); switch (type) { case "root": return getMetadataResult({ @@ -403,13 +456,9 @@ function bindSmapiSoapServiceToExpress( return await musicLibrary.artists(paging).then((result) => { const accessToken = accessTokens.mint(authToken); return getMetadataResult({ - mediaCollection: result.results.map((it) => ({ - itemType: "artist", - id: `artist:${it.id}`, - artistId: it.id, - title: it.name, - albumArtURI: defaultArtistArtURI(webAddress, accessToken, it), - })), + mediaCollection: result.results.map((it) => + artist(webAddress, accessToken, it) + ), index: paging._index, total: result.total, }); @@ -451,13 +500,28 @@ function bindSmapiSoapServiceToExpress( total, }); }); + case "relatedArtists": + return await musicLibrary + .artist(typeId!) + .then((artist) => artist.similarArtists) + .then(slice2(paging)) + .then(([page, total]) => { + const accessToken = accessTokens.mint(authToken); + return getMetadataResult({ + mediaCollection: page.map((it) => + artist(webAddress, accessToken, it) + ), + index: paging._index, + total, + }); + }); case "album": return await musicLibrary .tracks(typeId!) .then(slice2(paging)) .then(([page, total]) => { const accessToken = accessTokens.mint(authToken); - return getMetadataResult2({ + return getMetadataResult({ mediaMetadata: page.map((it) => track(webAddress, accessToken, it) ), diff --git a/src/sonos.ts b/src/sonos.ts index a73c008..bb1e916 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -5,9 +5,26 @@ 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" export const STRINGS_VERSION = "2"; export const PRESENTATION_MAP_VERSION = "7"; +export type Capability = + | "search" + | "trFavorites" + | "alFavorites" + | "ucPlaylists" + | "extendedMD" + | "contextHeaders" + | "authorizationHeader"; + +export const BONOB_CAPABILITIES: Capability[] = [ + // "search", + // "trFavorites", + // "alFavorites", + // "ucPlaylists", + "extendedMD", +]; export type Device = { name: string; @@ -103,6 +120,7 @@ export const asCustomdForm = (csrfToken: string, service: Service) => ({ manifestVersion: "0", manifestUri: "", containerType: "MService", + caps: BONOB_CAPABILITIES, }); const setupDiscovery = ( @@ -156,7 +174,9 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos { return false; } - logger.info(`Registering ${service.name}(SID:${service.sid}) with sonos device ${anyDevice.Name} @ ${anyDevice.Host}`) + logger.info( + `Registering ${service.name}(SID:${service.sid}) with sonos device ${anyDevice.Name} @ ${anyDevice.Host}` + ); const customd = `http://${anyDevice.Host}:${anyDevice.Port}/customsd`; @@ -175,7 +195,7 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos { } return axios - .post(customd, new URLSearchParams(asCustomdForm(csrfToken, service)), { + .post(customd, new URLSearchParams(qs.stringify(asCustomdForm(csrfToken, service))), { headers: { "Content-Type": "application/x-www-form-urlencoded", }, diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 568736f..7c9f78f 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -14,10 +14,10 @@ import { STRINGS_ROUTE, LOGIN_ROUTE, getMetadataResult, - getMetadataResult2, PRESENTATION_MAP_ROUTE, SONOS_RECOMMENDED_IMAGE_SIZES, track, + album, defaultAlbumArtURI, defaultArtistArtURI, } from "../src/smapi"; @@ -96,18 +96,20 @@ describe("service config", () => { }); describe("getMetadataResult", () => { - describe("when there are a zero mediaCollections", () => { + describe("when there are a no mediaCollections & no mediaMetadata", () => { it("should have zero count", () => { const result = getMetadataResult({ - mediaCollection: [], index: 33, total: 99, }); - expect(result.getMetadataResult.count).toEqual(0); - expect(result.getMetadataResult.index).toEqual(33); - expect(result.getMetadataResult.total).toEqual(99); - expect(result.getMetadataResult.mediaCollection).toEqual([]); + expect(result).toEqual({ + getMetadataResult: { + count: 0, + index: 33, + total: 99, + }, + }); }); }); @@ -120,10 +122,57 @@ describe("getMetadataResult", () => { total: 3, }); - expect(result.getMetadataResult.count).toEqual(2); - expect(result.getMetadataResult.index).toEqual(22); - expect(result.getMetadataResult.total).toEqual(3); - expect(result.getMetadataResult.mediaCollection).toEqual(mediaCollection); + expect(result).toEqual({ + getMetadataResult: { + count: 2, + index: 22, + total: 3, + mediaCollection, + }, + }); + }); + }); + + describe("when there are a number of mediaMetadata", () => { + it("should add correct counts", () => { + const mediaMetadata = [{}, {}]; + const result = getMetadataResult({ + mediaMetadata, + index: 22, + total: 3, + }); + + expect(result).toEqual({ + getMetadataResult: { + count: 2, + index: 22, + total: 3, + mediaMetadata, + }, + }); + }); + }); + + describe("when there are both a number of mediaMetadata & mediaCollections", () => { + it("should sum the counts", () => { + const mediaCollection = [{}, {}, {}]; + const mediaMetadata = [{}, {}]; + const result = getMetadataResult({ + mediaCollection, + mediaMetadata, + index: 22, + total: 3, + }); + + expect(result).toEqual({ + getMetadataResult: { + count: 5, + index: 22, + total: 3, + mediaCollection, + mediaMetadata, + }, + }); }); }); }); @@ -165,6 +214,22 @@ describe("track", () => { }); }); +describe("album", () => { + it("should map to a sonos album", () => { + const webAddress = "http://localhost:9988"; + const accessToken = uuid(); + const someAlbum = anAlbum({ id: "id123", name: "What a great album" }); + + expect(album(webAddress, accessToken, someAlbum)).toEqual({ + itemType: "album", + id: `album:${someAlbum.id}`, + title: someAlbum.name, + albumArtURI: defaultAlbumArtURI(webAddress, accessToken, someAlbum), + canPlay: true, + }); + }); +}); + describe("defaultAlbumArtURI", () => { it("should create the correct URI", () => { const webAddress = "http://localhost:1234"; @@ -576,7 +641,7 @@ describe("api", () => { id: `album:${it.id}`, title: it.name, albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), - canPlay: true + canPlay: true, })), index: 0, total: artistWithManyAlbums.albums.length, @@ -602,7 +667,7 @@ describe("api", () => { id: `album:${it.id}`, title: it.name, albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), - canPlay: true + canPlay: true, })), index: 2, total: artistWithManyAlbums.albums.length, @@ -663,7 +728,11 @@ describe("api", () => { id: `artist:${it.id}`, artistId: it.id, title: it.name, - albumArtURI: defaultArtistArtURI(rootUrl, accessToken, it), + albumArtURI: defaultArtistArtURI( + rootUrl, + accessToken, + it + ), }) ), index: 1, @@ -674,6 +743,117 @@ describe("api", () => { }); }); + describe("asking for relatedArtists", () => { + describe("when the artist has many", () => { + const relatedArtist1 = anArtist(); + const relatedArtist2 = anArtist(); + const relatedArtist3 = anArtist(); + const relatedArtist4 = anArtist(); + + const artist = anArtist({ + similarArtists: [ + relatedArtist1, + relatedArtist2, + relatedArtist3, + relatedArtist4, + ], + }); + + beforeEach(() => { + musicService.hasArtists( + artist, + relatedArtist1, + relatedArtist2, + relatedArtist3, + relatedArtist4 + ); + }); + + describe("when they fit on one page", () => { + it("should return them", async () => { + const result = await ws.getMetadataAsync({ + id: `relatedArtists:${artist.id}`, + index: 0, + count: 100, + }); + expect(result[0]).toEqual( + getMetadataResult({ + mediaCollection: [ + relatedArtist1, + relatedArtist2, + relatedArtist3, + relatedArtist4, + ].map((it) => ({ + itemType: "artist", + id: `artist:${it.id}`, + artistId: it.id, + title: it.name, + albumArtURI: defaultArtistArtURI( + rootUrl, + accessToken, + it + ), + })), + index: 0, + total: 4, + }) + ); + }); + }); + + describe("when they dont fit on one page", () => { + it("should return them", async () => { + const result = await ws.getMetadataAsync({ + id: `relatedArtists:${artist.id}`, + index: 1, + count: 2, + }); + expect(result[0]).toEqual( + getMetadataResult({ + mediaCollection: [relatedArtist2, relatedArtist3].map( + (it) => ({ + itemType: "artist", + id: `artist:${it.id}`, + artistId: it.id, + title: it.name, + albumArtURI: defaultArtistArtURI( + rootUrl, + accessToken, + it + ), + }) + ), + index: 1, + total: 4, + }) + ); + }); + }); + }); + + describe("when the artist has none", () => { + const artist = anArtist({ similarArtists: [] }); + + beforeEach(() => { + musicService.hasArtists(artist); + }); + + it("should return an empty list", async () => { + const result = await ws.getMetadataAsync({ + id: `relatedArtists:${artist.id}`, + index: 0, + count: 100, + }); + expect(result[0]).toEqual( + getMetadataResult({ + index: 0, + total: 0, + }) + ); + }); + }); + }); + describe("asking for albums", () => { const artist1 = anArtist({ albums: [anAlbum(), anAlbum(), anAlbum()], @@ -708,7 +888,7 @@ describe("api", () => { id: `album:${it.id}`, title: it.name, albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), - canPlay: true + canPlay: true, })), index: 0, total: 6, @@ -735,7 +915,7 @@ describe("api", () => { id: `album:${it.id}`, title: it.name, albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it), - canPlay: true + canPlay: true, })), index: 2, total: 6, @@ -771,7 +951,7 @@ describe("api", () => { count: 100, }); expect(result[0]).toEqual( - getMetadataResult2({ + getMetadataResult({ mediaMetadata: [ track1, track2, @@ -796,7 +976,7 @@ describe("api", () => { count: 2, }); expect(result[0]).toEqual( - getMetadataResult2({ + getMetadataResult({ mediaMetadata: [track3, track4].map((it) => track(rootUrl, accessTokens.mint(token.authToken), it) ), @@ -811,6 +991,149 @@ describe("api", () => { }); }); + describe("getExtendedMetadata", () => { + const server = makeServer( + SONOS_DISABLED, + service, + rootUrl, + musicService, + linkCodes, + accessTokens + ); + + describe("when no credentials header provided", () => { + it("should return a fault of LoginUnsupported", async () => { + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + + await ws + .getExtendedMetadataAsync({ id: "root", index: 0, count: 0 }) + .then(() => fail("shouldnt get here")) + .catch((e: any) => { + expect(e.root.Envelope.Body.Fault).toEqual({ + faultcode: "Client.LoginUnsupported", + faultstring: "Missing credentials...", + }); + }); + }); + }); + + describe("when invalid credentials are provided", () => { + it("should return a fault of LoginUnauthorized", async () => { + const username = "userThatGetsDeleted"; + const password = "password1"; + musicService.hasUser({ username, password }); + const token = (await musicService.generateToken({ + username, + password, + })) as AuthSuccess; + musicService.hasNoUsers(); + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + + ws.addSoapHeader({ credentials: someCredentials(token.authToken) }); + await ws + .getExtendedMetadataAsync({ id: "root", index: 0, count: 0 }) + .then(() => fail("shouldnt get here")) + .catch((e: any) => { + expect(e.root.Envelope.Body.Fault).toEqual({ + faultcode: "Client.LoginUnauthorized", + faultstring: "Credentials not found...", + }); + }); + }); + }); + + describe("when valid credentials are provided", () => { + const username = "validUser"; + const password = "validPassword"; + let token: AuthSuccess; + let ws: Client; + + beforeEach(async () => { + musicService.hasUser({ username, password }); + token = (await musicService.generateToken({ + username, + password, + })) as AuthSuccess; + + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + ws.addSoapHeader({ credentials: someCredentials(token.authToken) }); + }); + + describe("asking for an artist", () => { + describe("when it has similar artists", () => { + const similar1 = anArtist(); + const similar2 = anArtist(); + + const artist = anArtist({ + similarArtists: [similar1, similar2], + albums: [], + }); + + beforeEach(() => { + musicService.hasArtists(artist); + }); + + it("should return a RELATED_ARTISTS browse option", async () => { + const root = await ws.getExtendedMetadataAsync({ + id: `artist:${artist.id}`, + index: 0, + count: 100, + }); + expect(root[0]).toEqual({ + getExtendedMetadataResult: { + count: "0", + index: "0", + total: "0", + relatedBrowse: [ + { + id: `relatedArtists:${artist.id}`, + type: "RELATED_ARTISTS", + }, + ], + }, + }); + }); + }); + + describe("when it has no similar artists", () => { + const artist = anArtist({ + similarArtists: [], + albums: [], + }); + + beforeEach(() => { + musicService.hasArtists(artist); + }); + + it("should not return a RELATED_ARTISTS browse option", async () => { + const root = await ws.getExtendedMetadataAsync({ + id: `artist:${artist.id}`, + index: 0, + count: 100, + }); + expect(root[0]).toEqual({ + getExtendedMetadataResult: { + count: "0", + index: "0", + total: "0", + }, + }); + }); + }); + }); + }); + }); + describe("getMediaURI", () => { const accessTokenMint = jest.fn(); diff --git a/tests/sonos.test.ts b/tests/sonos.test.ts index 8be66a4..93c544f 100644 --- a/tests/sonos.test.ts +++ b/tests/sonos.test.ts @@ -1,3 +1,4 @@ +import qs from "querystring" import { SonosManager, SonosDevice } from "@svrooij/sonos"; import { MusicServicesService, @@ -21,6 +22,7 @@ import sonos, { Service, STRINGS_VERSION, PRESENTATION_MAP_VERSION, + BONOB_CAPABILITIES, } from "../src/sonos"; import { aSonosDevice, aService } from "./builders"; @@ -209,6 +211,7 @@ describe("sonos", () => { manifestVersion: "0", manifestUri: "", containerType: "MService", + caps: BONOB_CAPABILITIES }); }); }); @@ -553,7 +556,7 @@ describe("sonos", () => { expect(mockPost).toHaveBeenCalledWith( `http://${device1.Host}:${device1.Port}/customsd`, - new URLSearchParams(asCustomdForm(csrfToken, serviceToAdd)), + new URLSearchParams(qs.stringify(asCustomdForm(csrfToken, serviceToAdd))), POST_CONFIG );