From e26cf9a400934a42669fdf26a4df0db705123ad9 Mon Sep 17 00:00:00 2001
From: Stephan van Rooij <1292510+svrooij@users.noreply.github.com>
Date: Sat, 30 Mar 2024 18:03:31 +0100
Subject: [PATCH] fix: Support for Stream URL (#193)
* fix: Support for stream urls fixed #192
* feat: Support for Group ID Fixed #182
Co-authored-by: H. Klages <17273119+hklages@users.noreply.github.com>
Co-authored-by: Stephan van Rooij <1292510+svrooij@users.noreply.github.com
---
docs/sonos-device/methods.md | 1 +
examples/use-manager.js | 5 +++-
src/helpers/metadata-helper.ts | 13 ++++++++--
src/models/sonos-events.ts | 4 +++
src/models/strong-sonos-events.ts | 1 +
src/sonos-device.ts | 26 +++++++++++++++++--
src/sonos-manager.ts | 10 ++++----
tests/sonos-device-metadata.test.ts | 40 +++++++++++++++++++++++++++++
8 files changed, 90 insertions(+), 10 deletions(-)
create mode 100644 tests/sonos-device-metadata.test.ts
diff --git a/docs/sonos-device/methods.md b/docs/sonos-device/methods.md
index f660d68..3bd11fe 100644
--- a/docs/sonos-device/methods.md
+++ b/docs/sonos-device/methods.md
@@ -83,6 +83,7 @@ Currently supported url's for metadata guessing:
- `spotify:playlist:37i9dQZF1DXcx1szy2g67M` - Spotify playlist (has to be added to queue).
- `spotify:user:spotify:playlist:37i9dQZF1DX1htCFhfVtyK` - Spotify playlist of a different user (summer rewind from user spotify in this case).
- `radio:s113577` - Tunein radio station.
+- `x-rincon-mp3radio://https://....` - Direct stream url (mp3 or acc).
This library will guess the metadata automatically for the following methods, but you can also use the **MetadataHelper**, yourself.
diff --git a/examples/use-manager.js b/examples/use-manager.js
index 0e8d71b..c81ff5b 100644
--- a/examples/use-manager.js
+++ b/examples/use-manager.js
@@ -11,13 +11,16 @@ manager.InitializeFromDevice(process.env.SONOS_HOST || '192.168.96.56')
.then(console.log)
.then(() => {
manager.Devices.forEach(d => {
- console.log('Device %s (%s) is joined in %s', d.Name, d.Uuid, d.GroupName)
+ console.log('Device %s (%s) is joined in %s (%s)', d.Name, d.Uuid, d.GroupName, d.GroupId)
d.Events.on(SonosEvents.Coordinator, Uuid => {
console.log('Coordinator for %s changed to %s', d.Name, Uuid)
})
d.Events.on(SonosEvents.GroupName, newName => {
console.log('Group name for %s changed to %s', d.Name, newName)
})
+ d.Events.on(SonosEvents.GroupId, id => {
+ console.log('Group Id for %s changed to %s', d.Name, id)
+ })
d.Events.on(SonosEvents.CurrentTrack, uri => {
console.log('Current Track for %s %s', d.Name, uri)
})
diff --git a/src/helpers/metadata-helper.ts b/src/helpers/metadata-helper.ts
index 5ff3148..fd806fc 100644
--- a/src/helpers/metadata-helper.ts
+++ b/src/helpers/metadata-helper.ts
@@ -49,8 +49,10 @@ export default class MetadataHelper {
const uri = Array.isArray(didlItem['upnp:albumArtURI']) ? didlItem['upnp:albumArtURI'][0] : didlItem['upnp:albumArtURI'];
// Github user @hklages discovered that the album uri sometimes doesn't work because of encoding:
// See https://github.com/svrooij/node-sonos-ts/issues/93 if you found and album art uri that doesn't work.
- const art = (uri as string).replace(/&/gi, '&'); // .replace(/%25/g, '%').replace(/%3a/gi, ':');
- track.AlbumArtUri = art.startsWith('http') ? art : `http://${host}:${port}${art}`;
+ if (typeof uri === 'string' && uri.length > 0) {
+ const art = (uri as string).replace(/&/gi, '&'); // .replace(/%25/g, '%').replace(/%3a/gi, ':');
+ track.AlbumArtUri = art.startsWith('http') ? art : `http://${host}:${port}${art}`;
+ }
}
if (didlItem.res) {
@@ -150,6 +152,13 @@ export default class MetadataHelper {
return track;
}
+ if (trackUri.startsWith('x-rincon-mp3radio://http')) {
+ track.TrackUri = trackUri;
+ track.ItemId = '-1';
+ //track.UpnpClass = 'object.item.audioItem.audioBroadcast';
+ return track;
+ }
+
if (trackUri.startsWith('x-rincon-cpcontainer:1006206ccatalog')) { // Amazon prime container
track.TrackUri = trackUri;
track.ItemId = trackUri.replace('x-rincon-cpcontainer:', '');
diff --git a/src/models/sonos-events.ts b/src/models/sonos-events.ts
index 58d2984..655f4ac 100644
--- a/src/models/sonos-events.ts
+++ b/src/models/sonos-events.ts
@@ -28,6 +28,10 @@ export enum SonosEvents {
* This event is emitted if the coordinator of this device changed
*/
Coordinator = 'coordinator',
+ /**
+ * This event is emitted if the group id changed
+ */
+ GroupId = 'groupid',
/**
* This event is emitted if the groupname changes.
*/
diff --git a/src/models/strong-sonos-events.ts b/src/models/strong-sonos-events.ts
index 84b450c..5f6015c 100644
--- a/src/models/strong-sonos-events.ts
+++ b/src/models/strong-sonos-events.ts
@@ -20,6 +20,7 @@ export type StrongSonosEvents = {
volume: (volume: number) => void;
coordinator: (uuid: string) => void;
+ groupid: (id: string) => void;
groupname: (name: string) => void;
subscriptionError: (error: EventsError) => void;
diff --git a/src/sonos-device.ts b/src/sonos-device.ts
index 3fefd35..b13b36b 100644
--- a/src/sonos-device.ts
+++ b/src/sonos-device.ts
@@ -37,6 +37,8 @@ export default class SonosDevice extends SonosDeviceBase {
private groupName: string | undefined;
+ private groupId: string | undefined;
+
private coordinator: SonosDevice | undefined;
/**
@@ -48,11 +50,12 @@ export default class SonosDevice extends SonosDeviceBase {
* @param {({coordinator?: SonosDevice; name: string; managerEvents: EventEmitter} | undefined)} [groupConfig=undefined] groupConfig is used by the SonosManager to setup group change events.
* @memberof SonosDevice
*/
- constructor(host: string, port = 1400, uuid: string | undefined = undefined, name: string | undefined = undefined, groupConfig: { coordinator?: SonosDevice; name: string; managerEvents: EventEmitter } | undefined = undefined) {
+ constructor(host: string, port = 1400, uuid: string | undefined = undefined, name: string | undefined = undefined, groupConfig: { coordinator?: SonosDevice; name: string; managerEvents: EventEmitter, groupId?: string } | undefined = undefined) {
super(host, port, uuid);
this.name = name;
if (groupConfig) {
this.groupName = groupConfig.name;
+ this.groupId = groupConfig.groupId;
if (groupConfig.coordinator !== undefined && uuid !== groupConfig.coordinator.uuid) {
this.coordinator = groupConfig.coordinator;
this.handleCoordinatorSimpleStateEvent(this.coordinator.currentTransportState === TransportState.Playing ? TransportState.Playing : TransportState.Stopped);
@@ -900,7 +903,7 @@ export default class SonosDevice extends SonosDeviceBase {
// #region Group stuff
private boundHandleGroupUpdate = this.handleGroupUpdate.bind(this);
- private handleGroupUpdate(data: { coordinator: SonosDevice | undefined; name: string }): void {
+ private handleGroupUpdate(data: { coordinator: SonosDevice | undefined; name: string; groupdId: string | undefined }): void {
if (data.coordinator && data.coordinator?.uuid !== this.Uuid && (!this.coordinator || this.coordinator?.Uuid !== data.coordinator?.Uuid)) {
this.debug('Coordinator changed for %s', this.uuid);
this.coordinator?.events?.removeListener('simpleTransportState', this.boundHandleCoordinatorSimpleStateEvent);
@@ -932,6 +935,14 @@ export default class SonosDevice extends SonosDeviceBase {
this.events.emit(SonosEvents.GroupName, this.groupName);
}
}
+
+ if (data.groupdId && data.groupdId !== this.groupId) {
+ this.groupId = data.groupdId;
+ this.debug('GroupId changed for %s to %s', this.uuid, this.groupId);
+ if (this.events !== undefined) {
+ this.events.emit(SonosEvents.GroupId, this.groupId);
+ }
+ }
}
/**
@@ -955,6 +966,17 @@ export default class SonosDevice extends SonosDeviceBase {
public get GroupName(): string | undefined {
return this.groupName;
}
+
+ /**
+ * Get the GroupId, if device is created by the SonosManager.
+ *
+ * @readonly
+ * @type {(string | undefined)}
+ * @memberof SonosDevice
+ */
+ public get GroupId(): string | undefined {
+ return this.groupId;
+ }
// #endregion
// #region Properties
diff --git a/src/sonos-manager.ts b/src/sonos-manager.ts
index 20bc10a..56bb664 100644
--- a/src/sonos-manager.ts
+++ b/src/sonos-manager.ts
@@ -96,12 +96,12 @@ export default class SonosManager {
private InitializeWithGroups(groups: ZoneGroup[]): boolean {
groups.forEach((g) => {
- const coordinator = new SonosDevice(g.coordinator.host, g.coordinator.port, g.coordinator.uuid, g.coordinator.name, { name: g.name, managerEvents: this.events });
+ const coordinator = new SonosDevice(g.coordinator.host, g.coordinator.port, g.coordinator.uuid, g.coordinator.name, { name: g.name, managerEvents: this.events, groupId: g.groupId });
if (this.devices.findIndex((v) => v.Uuid === coordinator.Uuid) === -1) this.devices.push(coordinator);
g.members.forEach((m) => {
// Check if device exists
if (this.devices.findIndex((v) => v.Uuid === m.uuid) === -1) {
- this.devices.push(new SonosDevice(m.host, m.port, m.uuid, m.name, { coordinator: m.uuid === g.coordinator.uuid ? undefined : coordinator, name: g.name, managerEvents: this.events }));
+ this.devices.push(new SonosDevice(m.host, m.port, m.uuid, m.name, { coordinator: m.uuid === g.coordinator.uuid ? undefined : coordinator, name: g.name, managerEvents: this.events, groupId: g.groupId }));
}
});
});
@@ -122,7 +122,7 @@ export default class SonosManager {
data.ZoneGroupState.forEach((g) => {
let coordinator = this.devices.find((d) => d.Uuid === g.coordinator.uuid);
if (coordinator === undefined) {
- coordinator = new SonosDevice(g.coordinator.host, g.coordinator.port, g.coordinator.uuid, g.coordinator.name, { coordinator: undefined, name: g.name, managerEvents: this.events });
+ coordinator = new SonosDevice(g.coordinator.host, g.coordinator.port, g.coordinator.uuid, g.coordinator.name, { coordinator: undefined, name: g.name, managerEvents: this.events, groupId: g.groupId });
this.devices.push(coordinator);
this.events.emit('NewDevice', coordinator);
}
@@ -131,13 +131,13 @@ export default class SonosManager {
g.members
.filter((m) => !this.devices.some((d) => d.Uuid === m.uuid))
.forEach((m) => {
- const newDevice = new SonosDevice(m.host, m.port, m.uuid, m.name, { coordinator: m.uuid === g.coordinator.uuid ? undefined : coordinator, name: g.name, managerEvents: this.events });
+ const newDevice = new SonosDevice(m.host, m.port, m.uuid, m.name, { coordinator: m.uuid === g.coordinator.uuid ? undefined : coordinator, name: g.name, managerEvents: this.events, groupId: g.groupId });
this.devices.push(newDevice);
this.events.emit('NewDevice', newDevice);
});
g.members.forEach((m) => {
- this.events.emit(m.uuid, { coordinator: g.coordinator.uuid === m.uuid ? undefined : coordinator, name: g.name });
+ this.events.emit(m.uuid, { coordinator: g.coordinator.uuid === m.uuid ? undefined : coordinator, name: g.name, groupId: g.groupId });
});
});
}
diff --git a/tests/sonos-device-metadata.test.ts b/tests/sonos-device-metadata.test.ts
new file mode 100644
index 0000000..555a091
--- /dev/null
+++ b/tests/sonos-device-metadata.test.ts
@@ -0,0 +1,40 @@
+import { expect } from 'chai'
+import { EventEmitter } from 'events';
+import { randomUUID } from 'crypto';
+import SonosDevice from '../src/sonos-device'
+import { TestHelpers } from './test-helpers';
+import SonosEventListener from '../src/sonos-event-listener';
+import { SmapiClient } from '../src/musicservices/smapi-client';
+import { PlayMode, Repeat, TransportState } from '../src/models';
+
+
+describe('SonosDevice Metadata', () => {
+ describe('SetTransportUri', () => {
+ // This tests fails for some reason, not sure is Artist radio ever worked.
+ it.skip('should work with artist radio', async() => {
+ const trackUri = 'spotify:artistRadio:72qVrKXRp9GeFQOesj0Pmv'
+ const scope = TestHelpers.mockRequestToService('/MediaRenderer/AVTransport/Control',
+ 'AVTransport',
+ 'SetAVTransportURI',
+ '0x-sonosapi-radio:spotify%3aartistRadio%3a72qVrKXRp9GeFQOesj0Pmv?sid=9&flags=8300&sn=2<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="100c206cspotify%3aartistRadio%3a72qVrKXRp9GeFQOesj0Pmv" parentID="10052064spotify%3aartist%3a72qVrKXRp9GeFQOesj0Pmv" restricted="true"><dc:title>Guus Meeuwis Radio</dc:title><upnp:class>object.item.audioItem.audioBroadcast.#artistRadio</upnp:class><dc:creator>Guus Meeuwis</dc:creator><upnp:albumArtURI>https://i.scdn.co/image/ab6761610000e5ebf5eda5ecd45cdbc2cc3c184a</upnp:albumArtURI><r:albumArtist>Guus Meeuwis</r:albumArtist><r:description>Guus Meeuwis</r:description><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">SA_RINCON2311_X_#Svc2311-0-Token</desc></item></DIDL-Lite>'
+ )
+ const device = new SonosDevice(TestHelpers.testHost, 1400);
+ const result = await device.SetAVTransportURI(trackUri);
+ expect(scope.isDone()).to.be.true;
+ expect(result).to.be.true
+ })
+
+ it('should work with a stream url', async() => {
+ const trackUri = 'x-rincon-mp3radio://https://kexp.streamguys1.com/kexp160.aac'
+ const scope = TestHelpers.mockRequestToService('/MediaRenderer/AVTransport/Control',
+ 'AVTransport',
+ 'SetAVTransportURI',
+ '0x-rincon-mp3radio://https://kexp.streamguys1.com/kexp160.aac<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="-1" restricted="true"><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">RINCON_AssociatedZPUDN</desc></item></DIDL-Lite>'
+ )
+ const device = new SonosDevice(TestHelpers.testHost, 1400);
+ const result = await device.SetAVTransportURI(trackUri);
+ expect(scope.isDone()).to.be.true;
+ expect(result).to.be.true
+ })
+ })
+});
\ No newline at end of file