diff --git a/README.md b/README.md index 8a0ae79..ddf1469 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ Currently only a single integration allowing Navidrome to be registered with son - Discovery of sonos devices using seed IP address - Auto register bonob service with sonos system - Multiple registrations within a single household. -- Transcoding performed by Navidrome with specific player for bonob/sonos +- Transcoding performed by Navidrome with specific player for bonob/sonos, customisable by mimeType +- Ability to search by Album, Artist, Track ## Running @@ -77,6 +78,5 @@ BONOB_STREAM_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom ## TODO -- Search - Artist Radio - Playlist support diff --git a/src/access_tokens.ts b/src/access_tokens.ts index 90898de..9be8d1b 100644 --- a/src/access_tokens.ts +++ b/src/access_tokens.ts @@ -1,13 +1,10 @@ -import dayjs, { Dayjs } from "dayjs"; +import { Dayjs } from "dayjs"; import { v4 as uuid } from "uuid"; import crypto from "crypto"; import { Encryption } from "./encryption"; import logger from "./logger"; - -export interface Clock { - now(): Dayjs; -} +import { Clock, SystemClock } from "./clock"; type AccessToken = { value: string; @@ -24,7 +21,7 @@ export class ExpiringAccessTokens implements AccessTokens { tokens = new Map(); clock: Clock; - constructor(clock: Clock = { now: () => dayjs() }) { + constructor(clock: Clock = SystemClock) { this.clock = clock; } diff --git a/src/clock.ts b/src/clock.ts new file mode 100644 index 0000000..adede94 --- /dev/null +++ b/src/clock.ts @@ -0,0 +1,7 @@ +import dayjs, { Dayjs } from "dayjs"; + +export interface Clock { + now(): Dayjs; +} + +export const SystemClock = { now: () => dayjs() }; diff --git a/src/music_service.ts b/src/music_service.ts index d076bb9..62813cb 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -155,4 +155,7 @@ export interface MusicLibrary { }): Promise; coverArt(id: string, type: "album" | "artist", size?: number): Promise; scrobble(id: string): Promise + searchArtists(query: string): Promise; + searchAlbums(query: string): Promise; + searchTracks(query: string): Promise; } diff --git a/src/navidrome.ts b/src/navidrome.ts index 34ea70d..3068a7d 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -166,6 +166,14 @@ export type GetSongResponse = { song: song; }; +export type Search3Response = SubsonicResponse & { + searchResult3: { + artist: artistSummary[]; + album: album[]; + song: song[]; + }; +}; + export function isError( subsonicResponse: SubsonicResponse ): subsonicResponse is SubsonicError { @@ -270,9 +278,9 @@ export class Navidrome implements MusicService { ...config, }) .then((response) => { - if (response.status != 200 && response.status != 206) - throw `Navidrome failed with a ${response.status}`; - else return response; + if (response.status != 200 && response.status != 206) { + throw `Navidrome failed with a ${response.status || "no!"} status`; + } else return response; }); getJSON = async ( @@ -290,6 +298,9 @@ export class Navidrome implements MusicService { "subsonic-response.album.song", "subsonic-response.genres.genre", "subsonic-response.artistInfo.similarArtist", + "subsonic-response.searchResult3.artist", + "subsonic-response.searchResult3.album", + "subsonic-response.searchResult3.song", ], }).xml2js(response.data) as SubconicEnvelope ) @@ -405,6 +416,26 @@ export class Navidrome implements MusicService { ) ); + toAlbumSummary = (albumList: album[]): AlbumSummary[] => + albumList.map((album) => ({ + id: album._id, + name: album._name, + year: album._year, + genre: maybeAsGenre(album._genre), + })); + + search3 = (credentials: Credentials, q: any) => + this.getJSON(credentials, "/rest/search3", { + artistCount: 0, + albumCount: 0, + songCount: 0, + ...q, + }).then((it) => ({ + artists: it.searchResult3.artist || [], + albums: it.searchResult3.album || [], + songs: it.searchResult3.song || [], + })); + async login(token: string) { const navidrome = this; const credentials: Credentials = this.parseToken(token); @@ -428,14 +459,7 @@ export class Navidrome implements MusicService { offset: q._index, }) .then((response) => response.albumList.album || []) - .then((albumList) => - albumList.map((album) => ({ - id: album._id, - name: album._name, - year: album._year, - genre: maybeAsGenre(album._genre), - })) - ) + .then(navidrome.toAlbumSummary) .then(slice2(q)) .then(([page, total]) => ({ results: page, @@ -555,6 +579,27 @@ export class Navidrome implements MusicService { }) .then((_) => true) .catch(() => false), + searchArtists: async (query: string) => + navidrome + .search3(credentials, { query, artistCount: 20 }) + .then(({ artists }) => + artists.map((artist) => ({ + id: artist._id, + name: artist._name, + })) + ), + searchAlbums: async (query: string) => + navidrome + .search3(credentials, { query, albumCount: 20 }) + .then(({ albums }) => navidrome.toAlbumSummary(albums)), + searchTracks: async (query: string) => + navidrome + .search3(credentials, { query, songCount: 20 }) + .then(({ songs }) => + Promise.all( + songs.map((it) => navidrome.getTrack(credentials, it._id)) + ) + ), }; return Promise.resolve(musicLibrary); diff --git a/src/server.ts b/src/server.ts index 283a37e..3ced1fe 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,7 @@ import { MusicService, isSuccess } from "./music_service"; import bindSmapiSoapServiceToExpress from "./smapi"; import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens"; import logger from "./logger"; +import { Clock, SystemClock } from "./clock"; export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token"; @@ -24,7 +25,8 @@ function server( webAddress: string | "http://localhost:4534", musicService: MusicService, linkCodes: LinkCodes = new InMemoryLinkCodes(), - accessTokens: AccessTokens = new AccessTokenPerAuthToken() + accessTokens: AccessTokens = new AccessTokenPerAuthToken(), + clock: Clock = SystemClock ): Express { const app = express(); @@ -125,6 +127,15 @@ function server( + + + + + + + + + `); }); @@ -198,7 +209,8 @@ function server( webAddress, linkCodes, musicService, - accessTokens + accessTokens, + clock ); return app; diff --git a/src/smapi.ts b/src/smapi.ts index adfdec5..c3318dc 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -18,6 +18,7 @@ import { } from "./music_service"; import { AccessTokens } from "./access_tokens"; import { BONOB_ACCESS_TOKEN_HEADER } from "./server"; +import { Clock } from "./clock"; export const LOGIN_ROUTE = "/login"; export const SOAP_PATH = "/ws/sonos"; @@ -107,6 +108,26 @@ export function getMetadataResult( }; } +export type SearchResponse = { + searchResult: getMetadataResult; +}; + +export function searchResult( + result: Partial +): SearchResponse { + const count = + (result?.mediaCollection?.length || 0) + + (result?.mediaMetadata?.length || 0); + return { + searchResult: { + count, + index: 0, + total: count, + ...result, + }, + }; +} + class SonosSoap { linkCodes: LinkCodes; webAddress: string; @@ -168,7 +189,7 @@ class SonosSoap { } export type Container = { - itemType: "container"; + itemType: "container" | "search"; id: string; title: string; }; @@ -185,6 +206,12 @@ const container = ({ title, }); +const search = ({ id, title }: { id: string; title: string }): Container => ({ + itemType: "search", + id, + title, +}); + const genre = (genre: Genre) => ({ itemType: "container", id: `genre:${genre.id}`, @@ -235,10 +262,10 @@ export const track = ( albumArtURI: defaultAlbumArtURI(webAddress, accessToken, track.album), artist: track.artist.name, artistId: track.artist.id, - duration: track.duration, + duration: `${track.duration}`, genre: track.album.genre?.name, genreId: track.album.genre?.id, - trackNumber: track.number, + trackNumber: `${track.number}`, }, }); @@ -300,7 +327,8 @@ function bindSmapiSoapServiceToExpress( webAddress: string, linkCodes: LinkCodes, musicService: MusicService, - accessTokens: AccessTokens + accessTokens: AccessTokens, + clock: Clock ) { const sonosSoap = new SonosSoap(webAddress, linkCodes); const soapyService = listen( @@ -312,6 +340,13 @@ function bindSmapiSoapServiceToExpress( getAppLink: () => sonosSoap.getAppLink(), getDeviceAuthToken: ({ linkCode }: { linkCode: string }) => sonosSoap.getDeviceAuthToken({ linkCode }), + getLastUpdate: () => ({ + getLastUpdateResult: { + favorites: clock.now().unix(), + catalog: clock.now().unix(), + pollInterval: 120, + }, + }), getMediaURI: async ( { id }: { id: string }, _, @@ -339,6 +374,46 @@ function bindSmapiSoapServiceToExpress( getMediaMetadataResult: track(webAddress, accessToken, it), })) ), + search: async ( + { id, term }: { id: string; term: string }, + _, + headers?: SoapyHeaders + ) => + auth(musicService, accessTokens, id, headers).then( + async ({ musicLibrary, accessToken }) => { + switch (id) { + case "albums": + return musicLibrary.searchAlbums(term).then((it) => + searchResult({ + count: it.length, + mediaCollection: it.map((albumSummary) => + album(webAddress, accessToken, albumSummary) + ), + }) + ); + case "artists": + return musicLibrary.searchArtists(term).then((it) => + searchResult({ + count: it.length, + mediaCollection: it.map((artistSummary) => + artist(webAddress, accessToken, artistSummary) + ), + }) + ); + case "tracks": + return musicLibrary.searchTracks(term).then((it) => + searchResult({ + count: it.length, + mediaCollection: it.map((aTrack) => + track(webAddress, accessToken, aTrack) + ), + }) + ); + default: + throw `Unsupported search by:${id}`; + } + } + ), getExtendedMetadata: async ( { id, @@ -436,6 +511,16 @@ function bindSmapiSoapServiceToExpress( index: 0, total: 8, }); + case "search": + return getMetadataResult({ + mediaCollection: [ + search({ id: "artists", title: "Artists" }), + search({ id: "albums", title: "Albums" }), + search({ id: "tracks", title: "Tracks" }), + ], + index: 0, + total: 3, + }); case "artists": return musicLibrary.artists(paging).then((result) => { return getMetadataResult({ diff --git a/src/sonos.ts b/src/sonos.ts index bb1e916..d055bc1 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -7,8 +7,8 @@ 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 const PRESENTATION_AND_STRINGS_VERSION = "12"; + export type Capability = | "search" | "trFavorites" @@ -19,7 +19,7 @@ export type Capability = | "authorizationHeader"; export const BONOB_CAPABILITIES: Capability[] = [ - // "search", + "search", // "trFavorites", // "alFavorites", // "ucPlaylists", @@ -59,11 +59,11 @@ export const bonobService = ( secureUri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`, strings: { uri: `${stripTailingSlash(bonobRoot)}${STRINGS_ROUTE}`, - version: STRINGS_VERSION, + version: PRESENTATION_AND_STRINGS_VERSION, }, presentation: { uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_ROUTE}`, - version: PRESENTATION_MAP_VERSION, + version: PRESENTATION_AND_STRINGS_VERSION, }, pollInterval: 1200, authType, diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 760d8b9..532cc8e 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -130,6 +130,9 @@ export class InMemoryMusicService implements MusicService { scrobble: async (_: string) => { return Promise.resolve(true); }, + searchArtists: async (_: string) => Promise.resolve([]), + searchAlbums: async (_: string) => Promise.resolve([]), + searchTracks: async (_: string) => Promise.resolve([]), }); } diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index aa0f270..cf7eb29 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -8,7 +8,7 @@ import { BROWSER_HEADERS, DODGY_IMAGE_NAME, asGenre, - appendMimeTypeToClientFor + appendMimeTypeToClientFor, } from "../src/navidrome"; import encryption from "../src/encryption"; @@ -72,18 +72,27 @@ describe("appendMimeTypeToUserAgentFor", () => { }); describe("when contains some mimeTypes", () => { - const streamUserAgent = appendMimeTypeToClientFor(["audio/flac", "audio/ogg"]) + const streamUserAgent = appendMimeTypeToClientFor([ + "audio/flac", + "audio/ogg", + ]); describe("and the track mimeType is in the array", () => { it("should return bonob+mimeType", () => { - expect(streamUserAgent(aTrack({ mimeType: "audio/flac"}))).toEqual("bonob+audio/flac") - expect(streamUserAgent(aTrack({ mimeType: "audio/ogg"}))).toEqual("bonob+audio/ogg") + expect(streamUserAgent(aTrack({ mimeType: "audio/flac" }))).toEqual( + "bonob+audio/flac" + ); + expect(streamUserAgent(aTrack({ mimeType: "audio/ogg" }))).toEqual( + "bonob+audio/ogg" + ); }); }); describe("and the track mimeType is not in the array", () => { it("should return bonob", () => { - expect(streamUserAgent(aTrack({ mimeType: "audio/mp3"}))).toEqual("bonob") + expect(streamUserAgent(aTrack({ mimeType: "audio/mp3" }))).toEqual( + "bonob" + ); }); }); }); @@ -94,7 +103,7 @@ const ok = (data: string) => ({ data, }); -const artistInfoXml = ( +const getArtistInfoXml = ( artist: Artist ) => ` @@ -162,14 +171,18 @@ const albumListXml = ( `; -const artistXml = ( +const artistXml = (artist: Artist) => ` + ${artist.albums.map((album) => + albumXml(artist, album) + )} + `; + +const getArtistXml = ( artist: Artist ) => ` - - ${artist.albums.map((album) => albumXml(artist, album))} - + ${artistXml(artist)} `; const genresXml = ( @@ -203,6 +216,32 @@ const getSongXml = ( )} `; +export type ArtistWithAlbum = { + artist: Artist; + album: Album; +}; + +const searchResult3 = ({ + artists, + albums, + tracks, +}: Partial<{ + artists: Artist[]; + albums: ArtistWithAlbum[]; + tracks: Track[]; +}>) => ` + + ${(artists || []).map((it) => + artistXml({ + ...it, + albums: [], + }) + )} + ${(albums || []).map((it) => albumXml(it.artist, it.album, []))} + ${(tracks || []).map((it) => songXml(it))} + +`; + const EMPTY = ``; const PING_OK = ``; @@ -214,7 +253,11 @@ describe("Navidrome", () => { const salt = "saltysalty"; const streamClientApplication = jest.fn(); - const navidrome = new Navidrome(url, encryption("secret"), streamClientApplication); + const navidrome = new Navidrome( + url, + encryption("secret"), + streamClientApplication + ); const mockedRandomString = (randomString as unknown) as jest.Mock; const mockGET = jest.fn(); @@ -356,10 +399,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ); }); @@ -419,10 +462,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ); }); @@ -482,10 +525,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ); }); @@ -545,10 +588,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ); }); @@ -603,10 +646,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ); }); @@ -655,10 +698,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ); }); @@ -705,10 +748,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ); }); @@ -1553,7 +1596,7 @@ describe("Navidrome", () => { describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => { it("should return undefined values", async () => { const stream = { - pipe: jest.fn() + pipe: jest.fn(), }; const streamResponse = { @@ -1592,7 +1635,7 @@ describe("Navidrome", () => { describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => { it("should return undefined values", async () => { const stream = { - pipe: jest.fn() + pipe: jest.fn(), }; const streamResponse = { @@ -1635,7 +1678,7 @@ describe("Navidrome", () => { describe("navidrome returns a 200", () => { it("should return the content", async () => { const stream = { - pipe: jest.fn() + pipe: jest.fn(), }; const streamResponse = { @@ -1712,7 +1755,7 @@ describe("Navidrome", () => { return expect( musicLibrary.stream({ trackId, range: undefined }) - ).rejects.toEqual(`Navidrome failed with a 400`); + ).rejects.toEqual(`Navidrome failed with a 400 status`); }); }); }); @@ -1720,7 +1763,7 @@ describe("Navidrome", () => { describe("with range specified", () => { it("should send the range to navidrome", async () => { const stream = { - pipe: jest.fn() + pipe: jest.fn(), }; const range = "1000-2000"; @@ -1780,7 +1823,7 @@ describe("Navidrome", () => { it("should user the custom StreamUserAgent when calling navidrome", async () => { const clientApplication = `bonob-${uuid()}`; streamClientApplication.mockReturnValue(clientApplication); - + const streamResponse = { status: 200, headers: { @@ -1788,27 +1831,29 @@ describe("Navidrome", () => { }, data: Buffer.from("the track", "ascii"), }; - + mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track)))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongXml(track))) + ) .mockImplementationOnce(() => Promise.resolve(ok(getAlbumXml(artist, album, [track]))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); - + await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.stream({ trackId, range: undefined })); - + expect(streamClientApplication).toHaveBeenCalledWith(track); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { params: { id: trackId, ...authParams, - c: clientApplication + c: clientApplication, }, headers: { "User-Agent": "bonob", @@ -1817,13 +1862,13 @@ describe("Navidrome", () => { }); }); }); - + describe("when range specified", () => { it("should user the custom StreamUserAgent when calling navidrome", async () => { const range = "1000-2000"; const clientApplication = `bonob-${uuid()}`; streamClientApplication.mockReturnValue(clientApplication); - + const streamResponse = { status: 200, headers: { @@ -1831,27 +1876,29 @@ describe("Navidrome", () => { }, data: Buffer.from("the track", "ascii"), }; - + mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track)))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongXml(track))) + ) .mockImplementationOnce(() => Promise.resolve(ok(getAlbumXml(artist, album, [track]))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); - + await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.stream({ trackId, range })); - + expect(streamClientApplication).toHaveBeenCalledWith(track); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { params: { id: trackId, ...authParams, - c: clientApplication + c: clientApplication, }, headers: { "User-Agent": "bonob", @@ -1968,10 +2015,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2035,10 +2082,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2113,10 +2160,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2180,10 +2227,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2256,10 +2303,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2335,10 +2382,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2403,10 +2450,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2482,10 +2529,10 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(artistXml(artist))) + Promise.resolve(ok(getArtistXml(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoXml(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2582,4 +2629,369 @@ describe("Navidrome", () => { }); }); }); + + describe("searchArtists", () => { + describe("when there is 1 search results", () => { + it("should return true", async () => { + const artist1 = anArtist({ name: "foo woo" }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(searchResult3({ artists: [artist1] }))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.searchArtists("foo")); + + expect(result).toEqual([artistToArtistSummary(artist1)]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: { + query: "foo", + artistCount: 20, + albumCount: 0, + songCount: 0, + ...authParams, + }, + headers, + }); + }); + }); + + describe("when there are many search results", () => { + it("should return true", async () => { + const artist1 = anArtist({ name: "foo woo" }); + const artist2 = anArtist({ name: "foo choo" }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(searchResult3({ artists: [artist1, artist2] }))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.searchArtists("foo")); + + expect(result).toEqual([ + artistToArtistSummary(artist1), + artistToArtistSummary(artist2), + ]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: { + query: "foo", + artistCount: 20, + albumCount: 0, + songCount: 0, + ...authParams, + }, + headers, + }); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(searchResult3({ artists: [] }))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.searchArtists("foo")); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: { + query: "foo", + artistCount: 20, + albumCount: 0, + songCount: 0, + ...authParams, + }, + headers, + }); + }); + }); + }); + + describe("searchAlbums", () => { + describe("when there is 1 search results", () => { + it("should return true", async () => { + const artist = anArtist({ name: "#1" }); + const album = anAlbum({ + name: "foo woo", + genre: { id: "pop", name: "pop" }, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(searchResult3({ albums: [{ artist, album }] }))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.searchAlbums("foo")); + + expect(result).toEqual([albumToAlbumSummary(album)]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: { + query: "foo", + albumCount: 20, + artistCount: 0, + songCount: 0, + ...authParams, + }, + headers, + }); + }); + }); + + describe("when there are many search results", () => { + it("should return true", async () => { + const artist1 = anArtist({ name: "artist1" }); + const album1 = anAlbum({ + name: "album1", + genre: { id: "pop", name: "pop" }, + }); + + const artist2 = anArtist({ name: "artist2" }); + const album2 = anAlbum({ + name: "album2", + genre: { id: "pop", name: "pop" }, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve( + ok( + searchResult3({ + albums: [ + { artist: artist1, album: album1 }, + { artist: artist2, album: album2 }, + ], + }) + ) + ) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.searchAlbums("moo")); + + expect(result).toEqual([ + albumToAlbumSummary(album1), + albumToAlbumSummary(album2), + ]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: { + query: "moo", + albumCount: 20, + artistCount: 0, + songCount: 0, + ...authParams, + }, + headers, + }); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(searchResult3({ albums: [] }))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.searchAlbums("foo")); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: { + query: "foo", + albumCount: 20, + artistCount: 0, + songCount: 0, + ...authParams, + }, + headers, + }); + }); + }); + }); + + describe("searchSongs", () => { + describe("when there is 1 search results", () => { + it("should return true", async () => { + const pop = asGenre("Pop"); + + const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(searchResult3({ tracks: [track] }))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track)))) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumXml(artist, album, []))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.searchTracks("foo")); + + expect(result).toEqual([track]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: { + query: "foo", + songCount: 20, + artistCount: 0, + albumCount: 0, + ...authParams, + }, + headers, + }); + }); + }); + + describe("when there are many search results", () => { + it("should return true", async () => { + const pop = asGenre("Pop"); + + const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist1 = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album1], + }); + const track1 = aTrack({ + id: "track1", + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + genre: pop, + }); + + const album2 = anAlbum({ id: "album2", name: "Bobbin", genre: pop }); + const artist2 = anArtist({ + id: "artist2", + name: "Jane Marley", + albums: [album2], + }); + const track2 = aTrack({ + id: "track2", + artist: artistToArtistSummary(artist2), + album: albumToAlbumSummary(album2), + genre: pop, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve( + ok( + searchResult3({ + tracks: [track1, track2], + }) + ) + ) + ) + .mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track1)))) + .mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track2)))) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumXml(artist1, album1, []))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumXml(artist2, album2, []))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.searchTracks("moo")); + + expect(result).toEqual([track1, track2]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: { + query: "moo", + songCount: 20, + artistCount: 0, + albumCount: 0, + ...authParams, + }, + headers, + }); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(searchResult3({ tracks: [] }))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.searchTracks("foo")); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { + params: { + query: "foo", + songCount: 20, + artistCount: 0, + albumCount: 0, + ...authParams, + }, + headers, + }); + }); + }); + }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 493960d..e088d61 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -17,9 +17,11 @@ import { PRESENTATION_MAP_ROUTE, SONOS_RECOMMENDED_IMAGE_SIZES, track, + artist, album, defaultAlbumArtURI, defaultArtistArtURI, + searchResult, } from "../src/smapi"; import { @@ -36,8 +38,13 @@ import { } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import supersoap from "./supersoap"; -import { artistToArtistSummary, MusicService } from "../src/music_service"; +import { + albumToAlbumSummary, + artistToArtistSummary, + MusicService, +} from "../src/music_service"; import { AccessTokens } from "../src/access_tokens"; +import dayjs from "dayjs"; const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); @@ -282,11 +289,17 @@ describe("api", () => { albums: jest.fn(), tracks: jest.fn(), track: jest.fn(), + searchArtists: jest.fn(), + searchAlbums: jest.fn(), + searchTracks: jest.fn(), }; const accessTokens = { mint: jest.fn(), authTokenFor: jest.fn(), }; + const clock = { + now: jest.fn(), + }; const server = makeServer( SONOS_DISABLED, @@ -294,7 +307,8 @@ describe("api", () => { rootUrl, (musicService as unknown) as MusicService, (linkCodes as unknown) as LinkCodes, - (accessTokens as unknown) as AccessTokens + (accessTokens as unknown) as AccessTokens, + clock ); beforeEach(() => { @@ -466,6 +480,179 @@ describe("api", () => { }); }); + describe("getLastUpdate", () => { + it("should return a result with some timestamps", async () => { + const now = dayjs(); + clock.now.mockReturnValue(now); + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + + const result = await ws.getLastUpdateAsync({}); + + expect(result[0]).toEqual({ + getLastUpdateResult: { + favorites: `${now.unix()}`, + catalog: `${now.unix()}`, + pollInterval: 120, + }, + }); + }); + }); + + describe("search", () => { + 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 + .getMetadataAsync({ id: "search", 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 () => { + musicService.login.mockRejectedValue("fail!"); + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + + ws.addSoapHeader({ credentials: someCredentials("someAuthToken") }); + await ws + .getMetadataAsync({ id: "search", 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 authToken = `authToken-${uuid()}`; + const accessToken = `accessToken-${uuid()}`; + let ws: Client; + + beforeEach(async () => { + musicService.login.mockResolvedValue(musicLibrary); + accessTokens.mint.mockReturnValue(accessToken); + + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + ws.addSoapHeader({ credentials: someCredentials(authToken) }); + }); + + describe("searching for albums", () => { + const album1 = anAlbum(); + const album2 = anAlbum(); + const albums = [album1, album2]; + + beforeEach(() => { + musicLibrary.searchAlbums.mockResolvedValue([ + albumToAlbumSummary(album1), + albumToAlbumSummary(album2), + ]); + }); + + it("should return the albums", async () => { + const term = "whoop"; + + const result = await ws.searchAsync({ + id: "albums", + term, + }); + expect(result[0]).toEqual( + searchResult({ + mediaCollection: albums.map((it) => + album(rootUrl, accessToken, albumToAlbumSummary(it)) + ), + index: 0, + total: 2, + }) + ); + expect(musicLibrary.searchAlbums).toHaveBeenCalledWith(term); + }); + }); + + describe("searching for artists", () => { + const artist1 = anArtist(); + const artist2 = anArtist(); + const artists = [artist1, artist2]; + + beforeEach(() => { + musicLibrary.searchArtists.mockResolvedValue([ + artistToArtistSummary(artist1), + artistToArtistSummary(artist2), + ]); + }); + + it("should return the artists", async () => { + const term = "whoopie"; + + const result = await ws.searchAsync({ + id: "artists", + term, + }); + expect(result[0]).toEqual( + searchResult({ + mediaCollection: artists.map((it) => + artist(rootUrl, accessToken, artistToArtistSummary(it)) + ), + index: 0, + total: 2, + }) + ); + expect(musicLibrary.searchArtists).toHaveBeenCalledWith(term); + }); + }); + + describe("searching for tracks", () => { + const track1 = aTrack(); + const track2 = aTrack(); + const tracks = [track1, track2]; + + beforeEach(() => { + musicLibrary.searchTracks.mockResolvedValue([track1, track2]); + }); + + it.only("should return the tracks", async () => { + const term = "whoopie"; + + const result = await ws.searchAsync({ + id: "tracks", + term, + }); + expect(result[0]).toEqual( + searchResult({ + mediaCollection: tracks.map((it) => track(rootUrl, accessToken, it)), + index: 0, + total: 2, + }) + ); + expect(musicLibrary.searchTracks).toHaveBeenCalledWith(term); + }); + }); + }); + }); + describe("getMetadata", () => { describe("when no credentials header provided", () => { it("should return a fault of LoginUnsupported", async () => { @@ -570,6 +757,27 @@ describe("api", () => { }); }); + describe("asking for the search container", () => { + it("should return it", async () => { + const root = await ws.getMetadataAsync({ + id: "search", + index: 0, + count: 100, + }); + expect(root[0]).toEqual( + getMetadataResult({ + mediaCollection: [ + { itemType: "search", id: "artists", title: "Artists" }, + { itemType: "search", id: "albums", title: "Albums" }, + { itemType: "search", id: "tracks", title: "Tracks" }, + ], + index: 0, + total: 3, + }) + ); + }); + }); + describe("asking for a genres", () => { const expectedGenres = [POP, PUNK, ROCK, TRIP_HOP]; @@ -984,7 +1192,7 @@ describe("api", () => { _count: paging.count, }); }); - }); + }); describe("asking for recently played albums", () => { const recentlyPlayed = [rock2, rock1, pop2]; @@ -1027,7 +1235,7 @@ describe("api", () => { _count: paging.count, }); }); - }); + }); describe("asking for most played albums", () => { const mostPlayed = [rock2, rock1, pop2]; @@ -1070,8 +1278,8 @@ describe("api", () => { _count: paging.count, }); }); - }); - + }); + describe("asking for recently added albums", () => { const recentlyAdded = [pop4, pop3, pop2]; @@ -1113,7 +1321,7 @@ describe("api", () => { _count: paging.count, }); }); - }); + }); describe("asking for all albums", () => { beforeEach(() => { @@ -1377,7 +1585,7 @@ describe("api", () => { describe("when invalid credentials are provided", () => { it("should return a fault of LoginUnauthorized", async () => { - musicService.login.mockRejectedValue("booom!") + musicService.login.mockRejectedValue("booom!"); const ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, @@ -1399,7 +1607,7 @@ describe("api", () => { describe("when valid credentials are provided", () => { let ws: Client; - const authToken = `authToken-${uuid()}` + const authToken = `authToken-${uuid()}`; const accessToken = `accessToken-${uuid()}`; beforeEach(async () => { @@ -1425,7 +1633,7 @@ describe("api", () => { }); beforeEach(() => { - musicLibrary.artist.mockResolvedValue(artist) + musicLibrary.artist.mockResolvedValue(artist); }); describe("when all albums fit on a page", () => { @@ -1434,18 +1642,20 @@ describe("api", () => { index: 0, count: 100, }; - + const root = await ws.getExtendedMetadataAsync({ id: `artist:${artist.id}`, - ...paging + ...paging, }); - + expect(root[0]).toEqual({ getExtendedMetadataResult: { count: "3", index: "0", total: "3", - mediaCollection: artist.albums.map(it => album(rootUrl, accessToken, it)) + mediaCollection: artist.albums.map((it) => + album(rootUrl, accessToken, it) + ), }, }); }); @@ -1457,22 +1667,24 @@ describe("api", () => { index: 1, count: 2, }; - + const root = await ws.getExtendedMetadataAsync({ id: `artist:${artist.id}`, - ...paging + ...paging, }); - + expect(root[0]).toEqual({ getExtendedMetadataResult: { count: "2", index: "1", total: "3", - mediaCollection: [album2, album3].map(it => album(rootUrl, accessToken, it)) + mediaCollection: [album2, album3].map((it) => + album(rootUrl, accessToken, it) + ), }, }); }); - }); + }); }); describe("when it has similar artists", () => { @@ -1485,7 +1697,7 @@ describe("api", () => { }); beforeEach(() => { - musicLibrary.artist.mockResolvedValue(artist) + musicLibrary.artist.mockResolvedValue(artist); }); it("should return a RELATED_ARTISTS browse option", async () => { @@ -1496,7 +1708,7 @@ describe("api", () => { const root = await ws.getExtendedMetadataAsync({ id: `artist:${artist.id}`, - ...paging + ...paging, }); expect(root[0]).toEqual({ @@ -1523,7 +1735,7 @@ describe("api", () => { }); beforeEach(() => { - musicLibrary.artist.mockResolvedValue(artist) + musicLibrary.artist.mockResolvedValue(artist); }); it("should not return a RELATED_ARTISTS browse option", async () => { @@ -1568,7 +1780,7 @@ describe("api", () => { describe("when invalid credentials are provided", () => { it("should return a fault of LoginUnauthorized", async () => { - musicService.login.mockRejectedValue("Credentials not found") + musicService.login.mockRejectedValue("Credentials not found"); const ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, @@ -1589,12 +1801,12 @@ describe("api", () => { }); describe("when valid credentials are provided", () => { - const authToken = `authToken-${uuid()}` + const authToken = `authToken-${uuid()}`; let ws: Client; const accessToken = `temporaryAccessToken-${uuid()}`; beforeEach(async () => { - musicService.login.mockResolvedValue(musicLibrary) + musicService.login.mockResolvedValue(musicLibrary); accessTokens.mint.mockReturnValue(accessToken); ws = await createClientAsync(`${service.uri}?wsdl`, { @@ -1656,7 +1868,9 @@ describe("api", () => { httpClient: supersoap(server, rootUrl), }); - ws.addSoapHeader({ credentials: someCredentials("some invalid token") }); + ws.addSoapHeader({ + credentials: someCredentials("some invalid token"), + }); await ws .getMediaMetadataAsync({ id: "track:123" }) .then(() => fail("shouldnt get here")) @@ -1695,11 +1909,7 @@ describe("api", () => { }); expect(root[0]).toEqual({ - getMediaMetadataResult: track( - rootUrl, - accessToken, - someTrack - ), + getMediaMetadataResult: track(rootUrl, accessToken, someTrack), }); expect(musicService.login).toHaveBeenCalledWith(authToken); expect(accessTokens.mint).toHaveBeenCalledWith(authToken); diff --git a/tests/sonos.test.ts b/tests/sonos.test.ts index 93c544f..8326a33 100644 --- a/tests/sonos.test.ts +++ b/tests/sonos.test.ts @@ -20,8 +20,7 @@ import sonos, { asCustomdForm, bonobService, Service, - STRINGS_VERSION, - PRESENTATION_MAP_VERSION, + PRESENTATION_AND_STRINGS_VERSION, BONOB_CAPABILITIES, } from "../src/sonos"; @@ -119,11 +118,11 @@ describe("sonos", () => { secureUri: `http://bonob.example.com/ws/sonos`, strings: { uri: `http://bonob.example.com/sonos/strings.xml`, - version: STRINGS_VERSION, + version: PRESENTATION_AND_STRINGS_VERSION, }, presentation: { uri: `http://bonob.example.com/sonos/presentationMap.xml`, - version: PRESENTATION_MAP_VERSION, + version: PRESENTATION_AND_STRINGS_VERSION, }, pollInterval: 1200, authType: "AppLink", @@ -142,11 +141,11 @@ describe("sonos", () => { secureUri: `http://bonob.example.com/ws/sonos`, strings: { uri: `http://bonob.example.com/sonos/strings.xml`, - version: STRINGS_VERSION, + version: PRESENTATION_AND_STRINGS_VERSION, }, presentation: { uri: `http://bonob.example.com/sonos/presentationMap.xml`, - version: PRESENTATION_MAP_VERSION, + version: PRESENTATION_AND_STRINGS_VERSION, }, pollInterval: 1200, authType: "AppLink", @@ -165,11 +164,11 @@ describe("sonos", () => { secureUri: `http://bonob.example.com/ws/sonos`, strings: { uri: `http://bonob.example.com/sonos/strings.xml`, - version: STRINGS_VERSION, + version: PRESENTATION_AND_STRINGS_VERSION, }, presentation: { uri: `http://bonob.example.com/sonos/presentationMap.xml`, - version: PRESENTATION_MAP_VERSION, + version: PRESENTATION_AND_STRINGS_VERSION, }, pollInterval: 1200, authType: "DeviceLink",