diff --git a/app/package.json b/app/package.json index e02b1a0..f1ad505 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "sonos-controller-unofficial", "description": "Unoffical sonos controller for linux.", - "version": "0.2.0-beta2.1", + "version": "0.2.0-beta3", "author": "Pascal Opitz ", "main": "main.js", "dependencies": { diff --git a/package.json b/package.json index 12f0940..ada4614 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sonos-controller-unofficial", - "version": "0.2.0-beta2.1", + "version": "0.2.0-beta3", "description": "Unoffical sonos controller for linux", "main": "app/main.js", "homepage": "http://pascalopitz.github.io/unoffical-sonos-controller-for-linux/", diff --git a/src/ui/components/AlbumArt.jsx b/src/ui/components/AlbumArt.jsx index f36cff1..f93f289 100644 --- a/src/ui/components/AlbumArt.jsx +++ b/src/ui/components/AlbumArt.jsx @@ -1,7 +1,10 @@ +import _ from 'lodash'; + import React, { Component } from 'react'; import shallowCompare from 'shallow-compare'; import SonosService from '../services/SonosService'; +import { getByServiceId } from '../services/MusicServiceClient'; import { getClosest, @@ -28,12 +31,16 @@ export class AlbumArt extends Component { } async _loadImage() { - const { visible, failed, src } = this.state; + const { visible, failed, src, propsSrc } = this.state; // here we make sure it's still visible, a URL and hasn't failed previously if (!visible || failed) { return; } + if (this.loadPromise) { + this.loadPromise.cancel(); + } + this.loadPromise = this.makeLoadPromise(src); this.loadPromise.promise .then(() => { @@ -43,7 +50,7 @@ export class AlbumArt extends Component { loaded: true, }); }) - .catch((err) => { + .catch(async (err) => { if (err && err.isCancelled) { return; } @@ -52,6 +59,53 @@ export class AlbumArt extends Component { return; } + try { + const urlToParse = propsSrc.match(/^\//) + ? `http://localhost${propsSrc}` + : propsSrc; + + const parsed = new URL( + new URL(urlToParse).searchParams.get('u') + ); + + const sid = parsed.searchParams.get('sid'); + + if (!sid) { + return; + } + + const client = getByServiceId(sid); + + if (!client) { + return null; + } + + const response = await client.getExtendedMetadata( + decodeURIComponent(parsed.pathname).replace('.mp3', '') + ); + + const newSrc = _.get( + response, + 'mediaMetadata.trackMetadata.albumArtURI' + ); + + if (newSrc) { + this.setState( + { + src: newSrc, + loading: false, + }, + () => { + this._loadImage(); + } + ); + + return; + } + } catch (e) { + // noop + } + this.setState({ failed: true, loading: false, @@ -129,9 +183,14 @@ export class AlbumArt extends Component { if (visible && needsRecompute) { const sonos = SonosService._currentDevice; - const url = serviceId ? getServiceLogoUrl(serviceId) : src; + const url = + src && typeof src === 'object' && src._ + ? src._ + : serviceId + ? getServiceLogoUrl(serviceId) + : src; - if (url) { + if (url && typeof url === 'string') { const srcUrl = url.indexOf('https://') === 0 || url.indexOf('http://') === 0 || diff --git a/src/ui/components/BrowserListItem.jsx b/src/ui/components/BrowserListItem.jsx index b7f6e86..313d11d 100644 --- a/src/ui/components/BrowserListItem.jsx +++ b/src/ui/components/BrowserListItem.jsx @@ -99,7 +99,7 @@ class InlineMenu extends PureComponent { const isPlayNow = item.class === 'object.item.audioItem' || (item.metadata && - item.metadata.class === 'object.item.audioItem.audioBroadcast'); + item.class === 'object.item.audioItem.audioBroadcast'); const isService = item.action === 'service'; const isSonosPlaylist = item._raw && item._raw.parentID === 'SQ:'; @@ -192,6 +192,7 @@ export class BrowserListItem extends Component { if ( item.class === 'object.item.audioItem.musicTrack' || item.class === 'object.item.audioItem' || + item.streamMetadata || item.trackMetadata ) { this.props.playNow(item); diff --git a/src/ui/helpers/sonos.js b/src/ui/helpers/sonos.js index 1407b08..5730220 100644 --- a/src/ui/helpers/sonos.js +++ b/src/ui/helpers/sonos.js @@ -2,6 +2,8 @@ import { DeviceDiscovery, Sonos, Helpers, Services } from 'sonos'; import request from 'axios'; import _ from 'lodash'; +const TUNEIN_ID = 65031; + class ContentDirectoryEnhanced extends Services.ContentDirectory { _enumItems(resultcontainer) { if (resultcontainer === undefined) { @@ -59,6 +61,8 @@ class SonosEnhanced extends Sonos { data.AvailableServiceDescriptorList ); + console.log(servicesObj); + const serviceDescriptors = servicesObj.Services.Service.map((obj) => { const stringsUri = _.get(obj, 'Presentation.Strings.Uri'); const mapUri = _.get(obj, 'Presentation.PresentationMap.Uri'); @@ -75,17 +79,20 @@ class SonosEnhanced extends Sonos { const services = []; - data.AvailableServiceTypeList.split(',').forEach(async (t) => { - const serviceId = Math.floor(Math.abs((t - 7) / 256)) || Number(t); - const match = _.find(serviceDescriptors, { - Id: String(serviceId), - }); - - if (match) { - match.ServiceIDEncoded = Number(t); - services.push(match); + [TUNEIN_ID, ...data.AvailableServiceTypeList.split(',')].forEach( + async (t) => { + const serviceId = + Math.floor(Math.abs((t - 7) / 256)) || Number(t); + const match = _.find(serviceDescriptors, { + Id: String(serviceId), + }); + + if (match) { + match.ServiceIDEncoded = Number(t); + services.push(match); + } } - }); + ); return services; } diff --git a/src/ui/reduxActions/BrowserListActions.js b/src/ui/reduxActions/BrowserListActions.js index 5b8a94d..2efa3dc 100644 --- a/src/ui/reduxActions/BrowserListActions.js +++ b/src/ui/reduxActions/BrowserListActions.js @@ -1,4 +1,5 @@ import _ from 'lodash'; + import { createAction } from 'redux-actions'; import Constants from '../constants'; @@ -16,6 +17,8 @@ import { import { loadPlaylists, loadPlaylistItems } from './PlaylistActions'; +const TUNEIN_ID = 65031; + async function _fetchLineIns() { const { deviceSearches } = store.getState().sonosService; @@ -81,16 +84,17 @@ export async function _getItem(item) { const meta = client.encodeItemMetadata(uri, item, token); return { - uri: _.escape(uri), - metadata: meta, + uri, + ...meta, }; } const uri = await client.getMediaURI(item.id); + const meta = client.encodeItemMetadata(uri, item); return { - uri: _.escape(uri), - metadata: client.encodeItemMetadata(uri, item), + uri, + ...meta, }; } @@ -338,12 +342,10 @@ const selectBrowseServices = async (item) => { }; const selectService = async (item) => { - const client = new MusicServiceClient(item.service.service); - - if (client.auth !== 'Anonymous') { - client.setAuthToken(item.service.authToken.authToken); - client.setKey(item.service.authToken.privateKey); - } + const client = new MusicServiceClient( + item.service.service, + item.service.authToken || {} + ); const res = await client.getMetadata('root', 0, 100); const searchTermMap = await client.getSearchTermMap(); @@ -451,7 +453,11 @@ export const select = createAction( store.getState().browserList.history ); - if (item.serviceClient && item.itemType !== 'track') { + if ( + item.serviceClient && + item.itemType !== 'track' && + item.itemType !== 'stream' + ) { return await selectServiceMediaCollectionItem(item); } @@ -493,14 +499,18 @@ export const playNow = createAction( const item = await _getItem(eventTarget); if ( + _.get( + eventTarget, + 'serviceClient._serviceDefinition.ServiceIDEncoded' + ) === TUNEIN_ID + ) { + await sonos.playTuneinRadio(eventTarget.id, eventTarget.title); + await sonos.play(); + } else if ( item.metadata && - item.metadataRaw && - item.metadata.class === 'object.item.audioItem.audioBroadcast' + item.class === 'object.item.audioItem.audioBroadcast' ) { - await sonos.play({ - uri: item.uri, - metadata: item.metadataRaw, - }); + await sonos.setAVTransportURI(item); } else if (item.class && item.class === 'object.item.audioItem') { await sonos.play(item.uri); } else { diff --git a/src/ui/services/MusicServiceClient.js b/src/ui/services/MusicServiceClient.js index 8d49f61..8747a75 100644 --- a/src/ui/services/MusicServiceClient.js +++ b/src/ui/services/MusicServiceClient.js @@ -5,6 +5,8 @@ import { Helpers } from 'sonos'; import SonosService from '../services/SonosService'; +import store from '../reducers'; + const NS = 'http://www.sonos.com/Services/1.1'; const deviceProviderName = 'Sonos'; @@ -25,11 +27,22 @@ function stripNamespaces(xml) { } class MusicServiceClient { - constructor(serviceDefinition) { + constructor(serviceDefinition, { authToken, privateKey } = {}) { this._serviceDefinition = serviceDefinition; this.name = serviceDefinition.Name; this.auth = serviceDefinition.Auth; + + this.authToken = authToken; + this.key = privateKey; + } + + setAuthToken(token) { + this.authToken = token; + } + + setKey(key) { + this.key = key; } async _doRequest(uri, action, requestBody, headers, retry = false) { @@ -122,6 +135,12 @@ class MusicServiceClient { return 'x-rincon-cpcontainer:000c206c' + escape(trackId); } + if (itemType === 'stream') { + return `x-sonosapi-stream:${escape( + trackId + )}&sid=${serviceId}&flags=8224&sn=0`; + } + // TODO: figure out why this doesn't work for Soundcloud // if (itemType === 'track') { // return 'x-rincon-cpcontainer:00032020' + escape(trackId); @@ -133,10 +152,10 @@ class MusicServiceClient { } getServiceString(serviceType) { - // SA_RINCON3079_X_#Svc3079-0-Token return `SA_RINCON${serviceType}_X_#Svc${serviceType}-0-Token`; } + // TODO: maybe we can use node-sonos Helpers.GenerateMetadata etc??? encodeItemMetadata(uri, item, serviceString) { const TYPE_MAPPINGS = { track: { @@ -167,6 +186,11 @@ class MusicServiceClient { type: 'object.container.playlistContainer', token: '0006206c', }, + stream: { + type: 'object.item.audioItem.audioBroadcast', + token: 'F00092020', + parentId: 'parentID="L"', + }, program: { type: 'object.item.audioItem.audioBroadcast.#' + item.displayType, @@ -174,12 +198,15 @@ class MusicServiceClient { }, }; - let resourceString, id, trackData; - //let servceId = item.serviceClient._serviceDefinition.ServiceIDEncoded; + let resourceString, + id, + trackData, + parentId = ''; if (serviceString) { const prefix = TYPE_MAPPINGS[item.itemType].token; id = prefix + escape(item.id); + parentId = TYPE_MAPPINGS[item.itemType].parentId || ''; resourceString = `${serviceString}`; } else { id = '-1'; @@ -210,7 +237,7 @@ class MusicServiceClient { 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/"> - + ${resourceString} ${_.escape(item.title)} ${TYPE_MAPPINGS[item.itemType].type} @@ -218,15 +245,10 @@ class MusicServiceClient { `; - return didl; - } - - setAuthToken(token) { - this.authToken = token; - } - - setKey(key) { - this.key = key; + return { + metadata: didl, + class: TYPE_MAPPINGS[item.itemType].type, + }; } getDeviceLinkCode() { @@ -581,25 +603,44 @@ class MusicServiceClient { if (!this.searchTermMap && mapUri) { const res = await fetch(mapUri); - const body = await res.text(); - const e = await Helpers.ParseXml(stripNamespaces(body)); + if (res.status < 400) { + const body = await res.text(); + const e = await Helpers.ParseXml(stripNamespaces(body)); + + const map = _.find( + e.Presentation.PresentationMap, + (m) => !!_.get(m, 'Match.SearchCategories') + ); - const map = _.find( - e.Presentation.PresentationMap, - (m) => !!_.get(m, 'Match.SearchCategories') - ); + let searchCategories = _.get(map, 'Match.SearchCategories'); - let searchCategories = _.get(map, 'Match.SearchCategories'); + if (_.isArray(searchCategories)) { + searchCategories = searchCategories[0]; + } - if (_.isArray(searchCategories)) { - searchCategories = searchCategories[0]; + this.searchTermMap = _.get(searchCategories, 'Category'); } - - this.searchTermMap = _.get(searchCategories, 'Category'); } return this.searchTermMap; } } +export const getByServiceId = (sid) => { + const { + musicServices: { active: activeServices }, + } = store.getState(); + + const serviceDefinition = activeServices.find((s) => s.service.Id === sid); + + if (!serviceDefinition) { + return null; + } + + return new MusicServiceClient( + serviceDefinition.service, + serviceDefinition.authToken || {} + ); +}; + export default MusicServiceClient;