Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Stream URL #193

Merged
merged 4 commits into from
Mar 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/sonos-device/methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 4 additions & 1 deletion examples/use-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
13 changes: 11 additions & 2 deletions src/helpers/metadata-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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:', '');
Expand Down
4 changes: 4 additions & 0 deletions src/models/sonos-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
1 change: 1 addition & 0 deletions src/models/strong-sonos-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 24 additions & 2 deletions src/sonos-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export default class SonosDevice extends SonosDeviceBase {

private groupName: string | undefined;

private groupId: string | undefined;

private coordinator: SonosDevice | undefined;

/**
Expand All @@ -49,11 +51,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);
Expand Down Expand Up @@ -904,7 +907,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);
Expand Down Expand Up @@ -936,6 +939,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);
}
}
}

/**
Expand All @@ -959,6 +970,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
Expand Down
10 changes: 5 additions & 5 deletions src/sonos-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}
});
});
Expand All @@ -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);
}
Expand All @@ -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 });
});
});
}
Expand Down
40 changes: 40 additions & 0 deletions tests/sonos-device-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -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',
'<InstanceID>0</InstanceID><CurrentURI>x-sonosapi-radio:spotify%3aartistRadio%3a72qVrKXRp9GeFQOesj0Pmv?sid=9&amp;flags=8300&amp;sn=2</CurrentURI><CurrentURIMetaData>&lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt;&lt;item id=&quot;100c206cspotify%3aartistRadio%3a72qVrKXRp9GeFQOesj0Pmv&quot; parentID=&quot;10052064spotify%3aartist%3a72qVrKXRp9GeFQOesj0Pmv&quot; restricted=&quot;true&quot;&gt;&lt;dc:title&gt;Guus Meeuwis Radio&lt;/dc:title&gt;&lt;upnp:class&gt;object.item.audioItem.audioBroadcast.#artistRadio&lt;/upnp:class&gt;&lt;dc:creator&gt;Guus Meeuwis&lt;/dc:creator&gt;&lt;upnp:albumArtURI&gt;https://i.scdn.co/image/ab6761610000e5ebf5eda5ecd45cdbc2cc3c184a&lt;/upnp:albumArtURI&gt;&lt;r:albumArtist&gt;Guus Meeuwis&lt;/r:albumArtist&gt;&lt;r:description&gt;Guus Meeuwis&lt;/r:description&gt;&lt;desc id=&quot;cdudn&quot; nameSpace=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot;&gt;SA_RINCON2311_X_#Svc2311-0-Token&lt;/desc&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;</CurrentURIMetaData>'
)
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',
'<InstanceID>0</InstanceID><CurrentURI>x-rincon-mp3radio://https://kexp.streamguys1.com/kexp160.aac</CurrentURI><CurrentURIMetaData>&lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt;&lt;item id=&quot;-1&quot; restricted=&quot;true&quot;&gt;&lt;desc id=&quot;cdudn&quot; nameSpace=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot;&gt;RINCON_AssociatedZPUDN&lt;/desc&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;</CurrentURIMetaData>'
)
const device = new SonosDevice(TestHelpers.testHost, 1400);
const result = await device.SetAVTransportURI(trackUri);
expect(scope.isDone()).to.be.true;
expect(result).to.be.true
})
})
});
Loading