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");
});
});
});