diff --git a/README.md b/README.md index bfebc95..5993303 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Currently only a single integration allowing Navidrome to be registered with son - Artist Art - Album Art - View Related Artists via Artist -> '...' -> Menu -> Related Arists -- Track scrobbling +- Now playing & Track Scrobbling - Auto discovery of sonos devices - Discovery of sonos devices using seed IP address - Auto register bonob service with sonos system @@ -109,6 +109,7 @@ BONOB_NAVIDROME_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for cust - Within navidrome a new player will be created, 'bonob (username)', so you can configure transcoding specifically for sonos ## Implementing a different music source other than navidrome + - Implement the MusicService/MusicLibrary interface - Startup bonob with your new implementation. diff --git a/src/server.ts b/src/server.ts index ba096a8..9bd1ff7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -164,7 +164,7 @@ function server( ${SONOS_RECOMMENDED_IMAGE_SIZES.map( (size) => `` - )} + ).join("")} diff --git a/src/smapi.ts b/src/smapi.ts index 4064d97..94bc33e 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -242,6 +242,10 @@ export const album = ( title: album.name, albumArtURI: defaultAlbumArtURI(webAddress, accessToken, album), canPlay: true, + // defaults + // canScroll: false, + // canEnumerate: true, + // canAddToFavorites: true }); export const track = ( @@ -487,6 +491,26 @@ function bindSmapiSoapServiceToExpress( }, }, })); + case "album": + return musicLibrary.album(typeId).then((it) => ({ + getExtendedMetadataResult: { + mediaCollection: { + attributes: { + readOnly: true, + userContent: false, + renameable: false, + }, + ...album(webAddress, accessToken, it), + }, + // + // + // + // AL:123456 + // ALBUM_NOTES + // + // + }, + })); default: throw `Unsupported getExtendedMetadata id=${id}`; } @@ -784,10 +808,42 @@ function bindSmapiSoapServiceToExpress( } }) .then((_) => ({ removeFromContainerResult: { updateId: "" } })), + setPlayedSeconds: async ( + { id, seconds }: { id: string; seconds: string }, + _, + headers?: SoapyHeaders + ) => + auth(musicService, accessTokens, headers) + .then(splitId(id)) + .then(({ musicLibrary, type, typeId }) => { + switch (type) { + case "track": + musicLibrary.track(typeId).then(({ duration }) => { + if ( + (duration < 30 && +seconds >= 10) || + (duration >= 30 && +seconds >= 30) + ) { + musicLibrary.scrobble(typeId); + } + }); + break; + default: + logger.info("Unsupported scrobble", { id, seconds }); + break; + } + }) + .then((_) => ({ + setPlayedSecondsResult: {}, + })), }, }, }, - readFileSync(WSDL_FILE, "utf8") + readFileSync(WSDL_FILE, "utf8"), + (err: any, res: any) => { + if (err) { + logger.error("BOOOOM", { err, res }); + } + } ); soapyService.log = (type, data) => { diff --git a/src/sonos.ts b/src/sonos.ts index 70e47ee..c383aa9 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -5,10 +5,12 @@ 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" +import qs from "querystring"; export const PRESENTATION_AND_STRINGS_VERSION = "18"; +// NOTE: manifest requires https for the URL, +// otherwise you will get an error trying to register export type Capability = | "search" | "trFavorites" @@ -16,7 +18,9 @@ export type Capability = | "ucPlaylists" | "extendedMD" | "contextHeaders" - | "authorizationHeader"; + | "authorizationHeader" + | "logging" + | "manifest"; export const BONOB_CAPABILITIES: Capability[] = [ "search", @@ -24,6 +28,7 @@ export const BONOB_CAPABILITIES: Capability[] = [ // "alFavorites", "ucPlaylists", "extendedMD", + "logging", ]; export type Device = { @@ -38,13 +43,13 @@ export type Service = { sid: number; uri: string; secureUri: string; - strings: { uri?: string; version?: string }; - presentation: { uri?: string; version?: string }; + strings?: { uri?: string; version?: string }; + presentation?: { uri?: string; version?: string }; pollInterval?: number; authType: "Anonymous" | "AppLink" | "DeviceLink" | "UserId"; }; -const stripTailingSlash = (url: string) => +export const stripTailingSlash = (url: string) => url.endsWith("/") ? url.substring(0, url.length - 1) : url; export const bonobService = ( @@ -113,12 +118,10 @@ export const asCustomdForm = (csrfToken: string, service: Service) => ({ secureUri: service.secureUri, pollInterval: `${service.pollInterval || 1200}`, authType: service.authType, - stringsVersion: service.strings.version || "", - stringsUri: service.strings.uri || "", - presentationMapVersion: service.presentation.version || "", - presentationMapUri: service.presentation.uri || "", - manifestVersion: "0", - manifestUri: "", + stringsVersion: service.strings?.version || "0", + stringsUri: service.strings?.uri || "", + presentationMapVersion: service.presentation?.version || "0", + presentationMapUri: service.presentation?.uri || "", containerType: "MService", caps: BONOB_CAPABILITIES, }); @@ -193,9 +196,10 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos { ); return false; } - + const customdForm = asCustomdForm(csrfToken, service); + logger.info(`Registering with sonos @ ${customd}`, { customdForm }); return axios - .post(customd, new URLSearchParams(qs.stringify(asCustomdForm(csrfToken, service))), { + .post(customd, new URLSearchParams(qs.stringify(customdForm)), { headers: { "Content-Type": "application/x-www-form-urlencoded", }, diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index 8e14d03..dcfab20 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -89,11 +89,11 @@ class SonosDriver { expect(this.service.authType).toEqual("AppLink"); await request(this.server) - .get(this.stripServiceRoot(this.service.strings.uri!)) + .get(this.stripServiceRoot(this.service.strings!.uri!)) .expect(200); await request(this.server) - .get(this.stripServiceRoot(this.service.presentation.uri!)) + .get(this.stripServiceRoot(this.service.presentation!.uri!)) .expect(200); const client = await createClientAsync(`${this.service.uri}?wsdl`, { diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index e704fd3..342a2e3 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -291,6 +291,7 @@ describe("api", () => { genres: jest.fn(), playlists: jest.fn(), playlist: jest.fn(), + album: jest.fn(), albums: jest.fn(), tracks: jest.fn(), track: jest.fn(), @@ -301,6 +302,7 @@ describe("api", () => { addToPlaylist: jest.fn(), deletePlaylist: jest.fn(), removeFromPlaylist: jest.fn(), + scrobble: jest.fn(), }; const accessTokens = { mint: jest.fn(), @@ -1939,7 +1941,7 @@ describe("api", () => { }); describe("asking for a track", () => { - it("should return the albums", async () => { + it("should return the track", async () => { const track = aTrack(); musicLibrary.track.mockResolvedValue(track); @@ -1975,6 +1977,38 @@ describe("api", () => { expect(musicLibrary.track).toHaveBeenCalledWith(track.id); }); }); + + describe("asking for an album", () => { + it("should return the album", async () => { + const album = anAlbum(); + + musicLibrary.album.mockResolvedValue(album); + + const root = await ws.getExtendedMetadataAsync({ + id: `album:${album.id}`, + }); + + expect(root[0]).toEqual({ + getExtendedMetadataResult: { + mediaCollection: { + attributes: { + readOnly: "true", + userContent: "false", + renameable: "false", + }, + itemType: "album", + id: `album:${album.id}`, + title: album.name, + albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, album), + canPlay: true, + artistId: album.artistId, + artist: album.artistName, + }, + }, + }); + expect(musicLibrary.album).toHaveBeenCalledWith(album.id); + }); + }); }); }); @@ -2161,7 +2195,10 @@ describe("api", () => { const idOfNewPlaylist = uuid(); it("should create a playlist", async () => { - musicLibrary.createPlaylist.mockResolvedValue({ id: idOfNewPlaylist, name: title }); + musicLibrary.createPlaylist.mockResolvedValue({ + id: idOfNewPlaylist, + name: title, + }); const result = await ws.createContainerAsync({ title, @@ -2181,16 +2218,19 @@ describe("api", () => { describe("with a title and a seed track", () => { const title = "aNewPlaylist2"; - const trackId = 'track123'; - const idOfNewPlaylist = 'playlistId'; + const trackId = "track123"; + const idOfNewPlaylist = "playlistId"; it("should create a playlist with the track", async () => { - musicLibrary.createPlaylist.mockResolvedValue({ id: idOfNewPlaylist, name: title }); + musicLibrary.createPlaylist.mockResolvedValue({ + id: idOfNewPlaylist, + name: title, + }); musicLibrary.addToPlaylist.mockResolvedValue(true); const result = await ws.createContainerAsync({ title, - seedId: `track:${trackId}` + seedId: `track:${trackId}`, }); expect(result[0]).toEqual({ @@ -2202,9 +2242,11 @@ describe("api", () => { expect(musicService.login).toHaveBeenCalledWith(authToken); expect(accessTokens.mint).toHaveBeenCalledWith(authToken); expect(musicLibrary.createPlaylist).toHaveBeenCalledWith(title); - expect(musicLibrary.addToPlaylist).toHaveBeenCalledWith(idOfNewPlaylist, trackId); + expect(musicLibrary.addToPlaylist).toHaveBeenCalledWith( + idOfNewPlaylist, + trackId + ); }); - }); }); @@ -2238,102 +2280,271 @@ describe("api", () => { expect(accessTokens.mint).toHaveBeenCalledWith(authToken); expect(musicLibrary.deletePlaylist).toHaveBeenCalledWith(id); }); + }); - describe("addToContainer", () => { - const authToken = `authToken-${uuid()}`; - const accessToken = `accessToken-${uuid()}`; - const trackId = "track123"; - const playlistId = "parent123"; - - 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("addToContainer", () => { + const authToken = `authToken-${uuid()}`; + const accessToken = `accessToken-${uuid()}`; + const trackId = "track123"; + const playlistId = "parent123"; + + 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), }); - - it("should delete the playlist", async () => { - musicLibrary.addToPlaylist.mockResolvedValue(true); - - const result = await ws.addToContainerAsync({ - id: `track:${trackId}`, - parentId: `parent:${playlistId}` + ws.addSoapHeader({ credentials: someCredentials(authToken) }); + }); + + it("should delete the playlist", async () => { + musicLibrary.addToPlaylist.mockResolvedValue(true); + + const result = await ws.addToContainerAsync({ + id: `track:${trackId}`, + parentId: `parent:${playlistId}`, + }); + + expect(result[0]).toEqual({ addToContainerResult: { updateId: null } }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.addToPlaylist).toHaveBeenCalledWith( + playlistId, + trackId + ); + }); + }); + + describe("removeFromContainer", () => { + 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("removing tracks from a playlist", () => { + const playlistId = "parent123"; + + it("should remove the track from playlist", async () => { + musicLibrary.removeFromPlaylist.mockResolvedValue(true); + + const result = await ws.removeFromContainerAsync({ + id: `playlist:${playlistId}`, + indices: `1,6,9`, + }); + + expect(result[0]).toEqual({ + removeFromContainerResult: { updateId: null }, }); - - expect(result[0]).toEqual({ addToContainerResult: { updateId: null } }); expect(musicService.login).toHaveBeenCalledWith(authToken); expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.addToPlaylist).toHaveBeenCalledWith(playlistId, trackId); + expect(musicLibrary.removeFromPlaylist).toHaveBeenCalledWith( + playlistId, + [1, 6, 9] + ); }); }); - describe("removeFromContainer", () => { - 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("removing a playlist", () => { + const playlist1 = aPlaylist({ id: "p1" }); + const playlist2 = aPlaylist({ id: "p2" }); + const playlist3 = aPlaylist({ id: "p3" }); + const playlist4 = aPlaylist({ id: "p4" }); + const playlist5 = aPlaylist({ id: "p5" }); - describe("removing tracks from a playlist", () => { - const playlistId = "parent123"; - - it("should remove the track from playlist", async () => { - musicLibrary.removeFromPlaylist.mockResolvedValue(true); - - const result = await ws.removeFromContainerAsync({ - id: `playlist:${playlistId}`, - indices: `1,6,9` + it("should delete the playlist", async () => { + musicLibrary.playlists.mockResolvedValue([ + playlist1, + playlist2, + playlist3, + playlist4, + playlist5, + ]); + musicLibrary.deletePlaylist.mockResolvedValue(true); + + const result = await ws.removeFromContainerAsync({ + id: `playlists`, + indices: `0,2,4`, + }); + + expect(result[0]).toEqual({ + removeFromContainerResult: { updateId: null }, + }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.deletePlaylist).toHaveBeenCalledTimes(3); + expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith( + 1, + playlist1.id + ); + expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith( + 2, + playlist3.id + ); + expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith( + 3, + playlist5.id + ); + }); + }); + }); + + describe("setPlayedSeconds", () => { + 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("when id is for a track", () => { + const trackId = "123456"; + + function itShouldScroble({ + trackId, + secondsPlayed, + }: { + trackId: string; + secondsPlayed: number; + }) { + it("should scrobble", async () => { + musicLibrary.scrobble.mockResolvedValue(true); + + const result = await ws.setPlayedSecondsAsync({ + id: `track:${trackId}`, + seconds: `${secondsPlayed}`, }); - - expect(result[0]).toEqual({ removeFromContainerResult: { updateId: null } }); + + expect(result[0]).toEqual({ setPlayedSecondsResult: null }); expect(musicService.login).toHaveBeenCalledWith(authToken); expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.removeFromPlaylist).toHaveBeenCalledWith(playlistId, [1,6,9]); + expect(musicLibrary.track).toHaveBeenCalledWith(trackId); + expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId); + }); + } + + function itShouldNotScroble({ + trackId, + secondsPlayed, + }: { + trackId: string; + secondsPlayed: number; + }) { + it("should scrobble", async () => { + const result = await ws.setPlayedSecondsAsync({ + id: `track:${trackId}`, + seconds: `${secondsPlayed}`, + }); + + expect(result[0]).toEqual({ setPlayedSecondsResult: null }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.track).toHaveBeenCalledWith(trackId); + expect(musicLibrary.scrobble).not.toHaveBeenCalled(); + }); + } + + describe("when the track length is 30 seconds", () => { + beforeEach(() => { + musicLibrary.track.mockResolvedValue( + aTrack({ id: trackId, duration: 30 }) + ); + }); + + describe("when the played length is 30 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 30 }); + }); + + describe("when the played length is > 30 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 90 }); + }); + + describe("when the played length is < 30 seconds", () => { + itShouldNotScroble({ trackId, secondsPlayed: 29 }); }); }); - describe("removing a playlist", () => { - const playlist1 = aPlaylist({ id: 'p1' }); - const playlist2 = aPlaylist({ id: 'p2' }); - const playlist3 = aPlaylist({ id: 'p3' }); - const playlist4 = aPlaylist({ id: 'p4' }); - const playlist5 = aPlaylist({ id: 'p5' }); + describe("when the track length is > 30 seconds", () => { + beforeEach(() => { + musicLibrary.track.mockResolvedValue( + aTrack({ id: trackId, duration: 31 }) + ); + }); - it("should delete the playlist", async () => { - musicLibrary.playlists.mockResolvedValue([playlist1, playlist2, playlist3, playlist4, playlist5]); - musicLibrary.deletePlaylist.mockResolvedValue(true); - - const result = await ws.removeFromContainerAsync({ - id: `playlists`, - indices: `0,2,4` - }); - - expect(result[0]).toEqual({ removeFromContainerResult: { updateId: null } }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.deletePlaylist).toHaveBeenCalledTimes(3); - expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith(1, playlist1.id); - expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith(2, playlist3.id); - expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith(3, playlist5.id); + describe("when the played length is 30 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 30 }); + }); + + describe("when the played length is > 30 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 90 }); + }); + + describe("when the played length is < 30 seconds", () => { + itShouldNotScroble({ trackId, secondsPlayed: 29 }); + }); + }); + + describe("when the track length is 29 seconds", () => { + beforeEach(() => { + musicLibrary.track.mockResolvedValue( + aTrack({ id: trackId, duration: 29 }) + ); + }); + + describe("when the played length is 29 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 30 }); + }); + + describe("when the played length is > 29 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 30 }); + }); + + describe("when the played length is 10 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 10 }); + }); + + describe("when the played length is < 10 seconds", () => { + itShouldNotScroble({ trackId, secondsPlayed: 9 }); }); }); }); - }); + + describe("when the id is for something that isnt a track", () => { + it("should not scrobble", async () => { + const result = await ws.setPlayedSecondsAsync({ + id: `album:666`, + seconds: "100", + }); + + expect(result[0]).toEqual({ setPlayedSecondsResult: null }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.scrobble).not.toHaveBeenCalled(); + }); + }); + }); }); }); diff --git a/tests/sonos.test.ts b/tests/sonos.test.ts index 8326a33..9b52150 100644 --- a/tests/sonos.test.ts +++ b/tests/sonos.test.ts @@ -207,8 +207,6 @@ describe("sonos", () => { stringsUri: "http://strings.example.com", presentationMapVersion: "27", presentationMapUri: "http://presentation.example.com", - manifestVersion: "0", - manifestUri: "", containerType: "MService", caps: BONOB_CAPABILITIES }); @@ -230,9 +228,9 @@ describe("sonos", () => { }); const form = asCustomdForm(uuid(), service) expect(form.stringsUri).toEqual(""); - expect(form.stringsVersion).toEqual(""); + expect(form.stringsVersion).toEqual("0"); expect(form.presentationMapUri).toEqual(""); - expect(form.presentationMapVersion).toEqual(""); + expect(form.presentationMapVersion).toEqual("0"); }); }); });