import debug from 'debug'; import { Track } from '../models/track'; import XmlHelper from './xml-helper'; export default class MetadataHelper { private static debug = debug('sonos:metadata'); /** * ParseDIDLTrack will parse track metadata for you. * * @static * @param {*} didl Object from XmlParser * @param {string} host Sonos host, to make album uri an absolute url * @param {number} [port=1400] Sonos port, to make album uri an absolute url * @returns {Track} Parsed track * @memberof MetadataHelper */ static ParseDIDLTrack(didl: unknown, host: string, port = 1400): Track | undefined { if (typeof didl === 'undefined') return undefined; MetadataHelper.debug('Parsing DIDL %o', didl); const parsedItem = didl as { [key: string]: any }; const didlItem = (parsedItem['DIDL-Lite'] && parsedItem['DIDL-Lite'].item) ? parsedItem['DIDL-Lite'].item : parsedItem; const track: Track = { Album: XmlHelper.DecodeHtml(didlItem['upnp:album']), Artist: XmlHelper.DecodeHtml(didlItem['dc:creator']), AlbumArtUri: undefined, Title: XmlHelper.DecodeHtml(didlItem['dc:title']), UpnpClass: didlItem['upnp:class'], Duration: undefined, ItemId: didlItem.id ?? didlItem._id, // the previous xml parser was prefixing all attributes with _ ParentId: didlItem.parentID ?? didlItem.parentID, TrackUri: undefined, ProtocolInfo: undefined, ResMD: XmlHelper.DecodeHtml(didlItem['r:resMD']), }; if (didlItem['r:streamContent'] && typeof didlItem['r:streamContent'] === 'string' && track.Artist === undefined) { const streamContent = didlItem['r:streamContent'].split('-'); if (streamContent.length === 2) { track.Artist = XmlHelper.DecodeHtml(streamContent[0].trim()); track.Title = XmlHelper.DecodeHtml(streamContent[1].trim()); } else { track.Artist = XmlHelper.DecodeHtml(streamContent[0].trim()); if (didlItem['r:radioShowMd'] && typeof didlItem['r:radioShowMd'] === 'string') { const radioShowMd = didlItem['r:radioShowMd'].split(','); track.Title = XmlHelper.DecodeHtml(radioShowMd[0].trim()); } } } if (didlItem['upnp:albumArtURI']) { 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. 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) { // the previous xml parser was prefixing all attributes with _ track.Duration = didlItem.res.duration ?? didlItem.res._duration; track.TrackUri = XmlHelper.DecodeTrackUri(didlItem.res['#text']); track.ProtocolInfo = didlItem.res.protocolInfo ?? didlItem.res._protocolInfo; } return track; } /** * Track to MetaData will generate a XML string that can be used as MetaData * * @static * @param {Track} track The track do describe * @returns {string} XML string (be sure to encode before using) * @memberof MetadataHelper */ static TrackToMetaData(track: Track | undefined, includeResource = false, cdudn = 'RINCON_AssociatedZPUDN'): string { if (track === undefined) { return ''; } const localCdudn = track.CdUdn ?? cdudn; const protocolInfo = track.ProtocolInfo ?? 'http-get:*:audio/mpeg:*'; const itemId = track.ItemId ?? '-1'; let metadata = ''; metadata += `` : '>'}`; if (includeResource) metadata += `${track.TrackUri}`; if (track.AlbumArtUri !== undefined) metadata += `${track.AlbumArtUri}`; if (track.Title !== undefined) metadata += `${track.Title}`; if (track.Artist !== undefined) metadata += `${track.Artist}`; if (track.Album !== undefined) metadata += `${track.Album}`; if (track.UpnpClass !== undefined) metadata += `${track.UpnpClass}`; metadata += `${localCdudn}`; metadata += ''; return metadata; } static GuessMetaDataAndTrackUri(trackUri: string, musicServiceRegion?: string): { trackUri: string; metadata: Track | string } { const metadata = MetadataHelper.GuessTrack(trackUri, musicServiceRegion); return { trackUri: metadata === undefined || metadata.TrackUri === undefined ? trackUri : XmlHelper.DecodeTrackUri(metadata.TrackUri) ?? '', metadata: metadata || '', }; } /** * IsRadioStream checks if a (currently playing) track URI is a live/radio-style stream, as * opposed to a regular skippable track. Useful to decide whether to disable next/previous/ * shuffle controls in a UI. * * This is a synchronous, URI-pattern based guess with no device round-trip, intended for * callers that already have a `TrackUri` (e.g. from a `GetPositionInfo` poll or an AVTransport * event) and need an answer on every such event without an extra request per track change. * If a live round-trip to the device is acceptable, `AVTransportService.GetCurrentTransportActions()` * is the more authoritative source, since it reflects the actual current playback session. * * @static * @param {string | undefined} trackUri Internal Sonos track URI (e.g. from currently playing track) * @returns {boolean} true if the URI is a live stream that shouldn't be treated as skippable * @memberof MetadataHelper */ static IsRadioStream(trackUri: string | undefined): boolean { if (!trackUri) return false; return trackUri.startsWith('x-sonosapi-stream:') || trackUri.startsWith('x-sonosapi-radio:') || trackUri.startsWith('x-sonosapi-hls:') || trackUri.startsWith('x-rincon-stream:') || trackUri.startsWith('x-rincon-mp3radio:') || trackUri.startsWith('aac:') || trackUri.startsWith('pndrradio:') // Sonos Radio (Deezer-powered) delivers individual tracks via x-sonos-http, but they // are part of a radio stream and not individually skippable. || (trackUri.startsWith('x-sonos-http:') && trackUri.includes('-DZR:')); } /** * GetSimpleUri will convert an internal Sonos track URI back to a simple service URI * that can be used with GuessTrack or GuessMetaDataAndTrackUri. * * @static * @param {string} trackUri Internal Sonos track URI (e.g. from currently playing track) * @returns {string | undefined} Simple service URI (e.g. spotify:track:xxx) or undefined if not supported * @memberof MetadataHelper */ static GetSimpleUri(trackUri: string): string | undefined { // Normalize: decode percent-encoded colons and HTML-encoded ampersands, strip query string const normalized = trackUri.replace(/%3a/gi, ':').replace(/&/g, '&'); const withoutQuery = normalized.split('?')[0]; // Spotify track: x-sonos-spotify:spotify:track:xxx if (withoutQuery.startsWith('x-sonos-spotify:')) { return withoutQuery.slice('x-sonos-spotify:'.length); } // Spotify album: x-rincon-cpcontainer:1004206cspotify:album:xxx if (withoutQuery.startsWith('x-rincon-cpcontainer:1004206cspotify:')) { return withoutQuery.slice('x-rincon-cpcontainer:1004206c'.length); } // Spotify playlist: x-rincon-cpcontainer:1006206cspotify:playlist:xxx if (withoutQuery.startsWith('x-rincon-cpcontainer:1006206cspotify:')) { return withoutQuery.slice('x-rincon-cpcontainer:1006206c'.length); } // Spotify artist top tracks: x-rincon-cpcontainer:100e206cspotify:artistTopTracks:xxx if (withoutQuery.startsWith('x-rincon-cpcontainer:100e206cspotify:')) { return withoutQuery.slice('x-rincon-cpcontainer:100e206c'.length); } // Spotify user playlist: x-rincon-cpcontainer:10062a6cspotify:user:xxx if (withoutQuery.startsWith('x-rincon-cpcontainer:10062a6cspotify:')) { return withoutQuery.slice('x-rincon-cpcontainer:10062a6c'.length); } // Spotify artist radio: x-sonosapi-radio:spotify:artistRadio:xxx if (withoutQuery.startsWith('x-sonosapi-radio:spotify:')) { return withoutQuery.slice('x-sonosapi-radio:'.length); } // Deezer track: x-sonos-http:tr:xxx.mp3 const deezerTrackMatch = /^x-sonos-http:tr:(\d+)\.mp3/.exec(withoutQuery); if (deezerTrackMatch) return `deezer:track:${deezerTrackMatch[1]}`; // Deezer album: x-rincon-cpcontainer:1004006calbum-xxx const deezerAlbumMatch = /^x-rincon-cpcontainer:1004006calbum-(\d+)/.exec(withoutQuery); if (deezerAlbumMatch) return `deezer:album:${deezerAlbumMatch[1]}`; // Deezer artist top tracks: x-rincon-cpcontainer:10fe206ctracks-artist-xxx const deezerArtistMatch = /^x-rincon-cpcontainer:10fe206ctracks-artist-(\d+)/.exec(withoutQuery); if (deezerArtistMatch) return `deezer:artistTopTracks:${deezerArtistMatch[1]}`; // Deezer playlist: x-rincon-cpcontainer:1006006cplaylist_spotify%3aplaylist-xxx (or with decoded colon) const deezerPlaylistMatch = /^x-rincon-cpcontainer:1006006cplaylist_spotify(?:%3a|:)playlist-(\d+)/.exec(normalized.split('?')[0]); if (deezerPlaylistMatch) return `deezer:playlist:${deezerPlaylistMatch[1]}`; // Apple Music track: x-sonos-http:song:xxx.mp4 or x-sonos-http:librarytrack:xxx.mp4 const appleTrackMatch = /^x-sonos-http:(song|librarytrack):([.\d\w]+)\.mp4/.exec(withoutQuery); if (appleTrackMatch) { const kind = appleTrackMatch[1] === 'song' ? 'track' : appleTrackMatch[1]; return `apple:${kind}:${appleTrackMatch[2]}`; } // Apple Music album: x-rincon-cpcontainer:1004206c(libraryalbum|album):xxx const appleAlbumMatch = /^x-rincon-cpcontainer:1004206c(libraryalbum|album):([.\d\w]+)/.exec(withoutQuery); if (appleAlbumMatch) return `apple:${appleAlbumMatch[1]}:${appleAlbumMatch[2]}`; // Apple Music playlist: x-rincon-cpcontainer:1006206c(libraryplaylist|playlist):xxx const applePlaylistMatch = /^x-rincon-cpcontainer:1006206c(libraryplaylist|playlist):([.\d\w]+)/.exec(withoutQuery); if (applePlaylistMatch) return `apple:${applePlaylistMatch[1]}:${applePlaylistMatch[2]}`; // Internet radio: x-sonosapi-stream:sXXX const radioMatch = /^x-sonosapi-stream:(s\d+)/.exec(withoutQuery); if (radioMatch) return `radio:${radioMatch[1]}`; // Sonos playlist: file:///jffs/settings/savedqueues.rsq#7 const sonosPlaylistMatch = /^file:\/\/\/jffs\/settings\/savedqueues\.rsq#(\d+)/.exec(withoutQuery); if (sonosPlaylistMatch) return `sonos:playlist:${sonosPlaylistMatch[1]}`; MetadataHelper.debug('Don\'t know how to reverse this TrackUri %s', trackUri); return undefined; } static GuessTrack(trackUri: string, musicServiceRegion?: string): Track | undefined { MetadataHelper.debug('Guessing metadata for %s', trackUri); let title = ''; // Can someone create a test for the next line. const match = /.*\/(.*)$/g.exec(trackUri.replace(/\.[a-zA-Z0-9]{3}$/, '')); if (match) { [, title] = match; } const track: Track = { }; if (trackUri.startsWith('x-file-cifs')) { track.ItemId = trackUri.replace('x-file-cifs', 'S').replace(/\s/g, '%20'); track.Title = title.replace('%20', ' '); track.ParentId = 'A:TRACKS'; track.UpnpClass = this.GetUpnpClass(track.ParentId); track.TrackUri = trackUri; track.CdUdn = 'RINCON_AssociatedZPUDN'; return track; } if (trackUri.startsWith('file:///jffs/settings/savedqueues.rsq#') || trackUri.startsWith('sonos:playlist:')) { const queueId = trackUri.match(/\d+/g); if (queueId?.length === 1) { track.TrackUri = `file:///jffs/settings/savedqueues.rsq#${queueId[0]}`; track.UpnpClass = 'object.container.playlistContainer'; track.ItemId = `SQ:${queueId[0]}`; track.CdUdn = 'RINCON_AssociatedZPUDN'; return track; } } if (trackUri.startsWith('x-rincon-playlist')) { const parentMatch = /.*#(.*)\/.*/g.exec(trackUri); if (parentMatch === null) throw new Error('ParentID not found'); const parentID = parentMatch[1]; track.ItemId = `${parentID}/${title.replace(/\s/g, '%20')}`; track.Title = title.replace('%20', ' '); track.UpnpClass = this.GetUpnpClass(parentID); track.ParentId = parentID; track.CdUdn = 'RINCON_AssociatedZPUDN'; return track; } if (trackUri.startsWith('x-sonosapi-stream:')) { const stationIdMatch = /^x-sonosapi-stream:([^?]+)/.exec(trackUri); const stationId = stationIdMatch ? stationIdMatch[1] : ''; track.UpnpClass = 'object.item.audioItem.audioBroadcast'; track.Title = 'Some radio station'; track.ItemId = `10092020${stationId}`; 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:', ''); track.UpnpClass = 'object.container.playlistContainer'; return track; } if (trackUri.startsWith('x-rincon-cpcontainer:100d206cuser-fav')) { // Sound Cloud likes track.TrackUri = trackUri; track.ItemId = trackUri.replace('x-rincon-cpcontainer:', ''); track.UpnpClass = 'object.container.albumList'; track.CdUdn = 'SA_RINCON40967_X_#Svc40967-0-Token'; return track; } if (trackUri.startsWith('x-rincon-cpcontainer:1006206cplaylist')) { // Sound Cloud playlists track.TrackUri = trackUri; track.ItemId = trackUri.replace('x-rincon-cpcontainer:', ''); track.UpnpClass = 'object.container.playlistContainer'; track.CdUdn = 'SA_RINCON40967_X_#Svc40967-0-Token'; return track; } if (trackUri.startsWith('x-rincon-cpcontainer:1004006calbum-')) { // Deezer Album const numbers = trackUri.match(/\d+/g); if (numbers && numbers.length >= 2) { return MetadataHelper.deezerMetadata('album', numbers[1]); } } const appleAlbumItem = /x-rincon-cpcontainer:1004206c(libraryalbum|album):([.\d\w]+)(?:\?|$)/.exec(trackUri); if (appleAlbumItem) { // Apple Music Album return MetadataHelper.appleMetadata(appleAlbumItem[1], appleAlbumItem[2], musicServiceRegion); } const applePlaylistItem = /x-rincon-cpcontainer:1006206c(libraryplaylist|playlist):([.\d\w]+)(?:\?|$)/.exec(trackUri); if (applePlaylistItem) { // Apple Music Playlist return MetadataHelper.appleMetadata(applePlaylistItem[1], applePlaylistItem[2], musicServiceRegion); } const appleTrackItem = /x-sonos-http:(librarytrack|song):([.\d\w]+)\.mp4\?.*sid=204/.exec(trackUri); if (appleTrackItem) { // Apple Music Track return MetadataHelper.appleMetadata(appleTrackItem[1], appleTrackItem[2], musicServiceRegion); } if (trackUri.startsWith('x-rincon-cpcontainer:10fe206ctracks-artist-')) { // Deezer Artists Top Tracks const numbers = trackUri.match(/\d+/g); if (numbers && numbers.length >= 3) { return MetadataHelper.deezerMetadata('artistTopTracks', numbers[2], musicServiceRegion); } } if (trackUri.startsWith('x-rincon-cpcontainer:1006006cplaylist_spotify%3aplaylist-')) { // Deezer Playlist const numbers = trackUri.match(/\d+/g); if (numbers && numbers.length >= 3) { return MetadataHelper.deezerMetadata('playlist', numbers[2], musicServiceRegion); } } if (trackUri.startsWith('x-sonos-http:tr%3a') && trackUri.includes('sid=2')) { // Deezer Track const numbers = trackUri.match(/\d+/g); if (numbers && numbers.length >= 2) { return MetadataHelper.deezerMetadata('track', numbers[1], musicServiceRegion); } } const parts = trackUri.split(':'); if ((parts.length === 3 || parts.length === 5) && parts[0] === 'spotify') { return MetadataHelper.guessSpotifyMetadata(trackUri, parts[1], musicServiceRegion); } if (parts.length === 3 && parts[0] === 'deezer') { return MetadataHelper.deezerMetadata(parts[1], parts[2], musicServiceRegion); } if (parts.length === 3 && parts[0] === 'apple') { return MetadataHelper.appleMetadata(parts[1], parts[2], musicServiceRegion); } if (parts.length === 2 && parts[0] === 'radio' && parts[1].startsWith('s')) { const [, stationId] = parts; track.UpnpClass = 'object.item.audioItem.audioBroadcast'; track.Title = 'Some radio station'; track.ItemId = `10092020${stationId}`; track.TrackUri = `x-sonosapi-stream:${stationId}?sid=254&flags=8224&sn=0`; return track; } MetadataHelper.debug('Don\'t support this TrackUri (yet) %s', trackUri); return undefined; } private static guessSpotifyMetadata(trackUri: string, kind: string, spotifyRegion?: string): Track | undefined { const region = spotifyRegion ?? process.env.SONOS_REGION_SPOTIFY ?? '2311'; const spotifyUri = trackUri.replace(/:/g, '%3a'); const track: Track = { Title: '', CdUdn: `SA_RINCON${region}_X_#Svc${region}-0-Token`, }; switch (kind) { case 'album': track.TrackUri = `x-rincon-cpcontainer:1004206c${spotifyUri}?sid=9&flags=8300&sn=7`; track.ItemId = `0004206c${spotifyUri}`; track.UpnpClass = 'object.container.album.musicAlbum'; break; case 'artistRadio': track.TrackUri = `x-sonosapi-radio:${spotifyUri}?sid=9&flags=8300&sn=7`; track.ItemId = `100c206c${spotifyUri}`; track.Title = 'Artist radio'; track.UpnpClass = 'object.item.audioItem.audioBroadcast.#artistRadio'; track.ParentId = `10052064${spotifyUri.replace('artistRadio', 'artist')}`; break; case 'artistTopTracks': track.TrackUri = `x-rincon-cpcontainer:100e206c${spotifyUri}?sid=9&flags=8300&sn=7`; track.ItemId = `100e206c${spotifyUri}`; track.ParentId = `10052064${spotifyUri.replace('artistTopTracks', 'artist')}`; track.UpnpClass = 'object.container.playlistContainer'; break; case 'playlist': track.TrackUri = `x-rincon-cpcontainer:1006206c${spotifyUri}?sid=9&flags=8300&sn=7`; track.ItemId = `1006206c${spotifyUri}`; track.Title = 'Spotify playlist'; track.UpnpClass = 'object.container.playlistContainer'; track.ParentId = '10fe2664playlists'; break; case 'track': track.TrackUri = `x-sonos-spotify:${spotifyUri}?sid=9&flags=8224&sn=7`; track.ItemId = `00032020${spotifyUri}`; track.UpnpClass = 'object.item.audioItem.musicTrack'; break; case 'user': track.TrackUri = `x-rincon-cpcontainer:10062a6c${spotifyUri}?sid=9&flags=10860&sn=7`; track.ItemId = `10062a6c${spotifyUri}`; track.Title = 'User\'s playlist'; track.UpnpClass = 'object.container.playlistContainer'; track.ParentId = '10082664playlists'; break; default: MetadataHelper.debug('Don\'t support this Spotify uri %s', trackUri); return undefined; } return track; } private static deezerMetadata(kind: 'album' | 'artistTopTracks' | 'playlist' | 'track' | unknown, id: string, deezerRegion?: string): Track | undefined { const region = deezerRegion ?? process.env.SONOS_REGION_DEEZER ?? '519'; const track: Track = { CdUdn: `SA_RINCON${region}_X_#Svc${region}-0-Token`, }; switch (kind) { case 'album': track.TrackUri = `x-rincon-cpcontainer:1004006calbum-${id}?sid=2&flags=108&sn=23`; track.UpnpClass = 'object.container.album.musicAlbum.#HERO'; track.ItemId = `1004006calbum-${id}`; break; case 'artistTopTracks': track.TrackUri = `x-rincon-cpcontainer:10fe206ctracks-artist-${id}?sid=2&flags=8300&sn=23`; track.UpnpClass = 'object.container.#DEFAULT'; track.ItemId = `10fe206ctracks-artist-${id}`; break; case 'playlist': track.TrackUri = `x-rincon-cpcontainer:1006006cplaylist_spotify%3aplaylist-${id}?sid=2&flags=108&sn=23`; track.UpnpClass = 'object.container.playlistContainer.#DEFAULT'; track.ItemId = `1006006cplaylist_spotify%3aplaylist-${id}`; break; case 'track': track.TrackUri = `x-sonos-http:tr:${id}.mp3?sid=2&flags=8224&sn=23`; track.UpnpClass = 'object.item.audioItem.musicTrack.#DEFAULT'; track.ItemId = `10032020tr%3a${id}`; break; default: return undefined; } return track; } private static appleMetadata(kind: 'album' | 'libraryalbum' | 'track' | 'librarytrack' | 'song' | 'playlist' | 'libraryplaylist' | unknown, id: string, appleRegion?: string): Track | undefined { const region = appleRegion ?? process.env.SONOS_REGION_APPLE ?? '52231'; const track: Track = { Title: '', CdUdn: `SA_RINCON${region}_X_#Svc${region}-0-Token`, }; const trackLabels = { song: 'song', track: 'song', librarytrack: 'librarytrack' }; switch (kind) { case 'album': case 'libraryalbum': track.TrackUri = `x-rincon-cpcontainer:1004206c${kind}:${id}?sid=204`; track.ItemId = `1004206c${kind}%3a${id}`; track.UpnpClass = 'object.item.audioItem.musicAlbum'; track.ParentId = '00020000album%3a'; break; case 'playlist': case 'libraryplaylist': track.TrackUri = `x-rincon-cpcontainer:1006206c${kind}:${id}?sid=204`; track.ItemId = `1006206c${kind}%3a${id}`; track.UpnpClass = 'object.container.playlistContainer'; track.ParentId = '00020000playlist%3a'; break; case 'track': case 'librarytrack': case 'song': track.TrackUri = `x-sonos-http:${trackLabels[kind]}:${id}.mp4?sid=204`; track.ItemId = `10032020${trackLabels[kind]}%3a${id}`; track.UpnpClass = 'object.item.audioItem.musicTrack'; track.ParentId = '1004206calbum%3a'; break; default: MetadataHelper.debug('Don\'t support this Apple Music kind %s', kind); return undefined; } return track; } private static GetUpnpClass(parentID: string): string { switch (parentID) { case 'A:ALBUMS': return 'object.item.audioItem.musicAlbum'; case 'A:TRACKS': return 'object.item.audioItem.musicTrack'; case 'A:ALBUMARTIST': return 'object.item.audioItem.musicArtist'; case 'A:GENRE': return 'object.container.genre.musicGenre'; case 'A:COMPOSER': return 'object.container.person.composer'; default: return ''; } } }