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