import { compact } from 'lodash'; import { isObject, upgradeSpotifyPlaylistUri, uriSource, uriType, getFromUri, } from './helpers'; /** * Convert a string to JSON, after we've checked whether it needs * conversion or not. * * @param data String or Object * @return Object * */ const toJSON = (data) => { // Parse it try { const json = JSON.parse(data); return json; // Could not parse string } catch (e) { // Check if it's JSON already if (data.constructor === {}.constructor) { return data; } console.error('Could not convert non-JSON', string); } return {}; }; /** * Get a track's icon * @param track object * @return string * */ const getTrackIcon = function (current_track = false, core = false) { if (!core) return false; if (!current_track) return false; if (typeof (current_track.uri) === 'undefined') return false; if (typeof (core.items[current_track.uri]) === 'undefined') return false; const track = core.items[current_track.uri]; if (!track.images) return false; return formatImages(track.images).small; }; /** * Digest an array of Mopidy image objects into a universal format. We also re-write * image URLs to be absolute to the mopidy server (required for proxy setups). * * @param mopidy = obj (mopidy store object) * @param images = array * @return array * */ const digestMopidyImages = function (mopidy, images) { const digested = []; for (let i = 0; i < images.length; i++) { // Image object (ie from images.get) if (typeof images[i] === 'object') { // Accommodate backends that provide URIs vs URLs let { url } = images[i]; if (!url && images[i].uri) { url = images[i].uri; } // Amend our URL images[i].url = url; // Replace local images to point directly to our Mopidy server if (url && url.startsWith('/images/')) { url = `//${mopidy.host}:${mopidy.port}${url}`; } // String-based image } else if (typeof images[i] === 'string') { // Replace local images to point directly to our Mopidy server if (images[i].startsWith('/images/')) { images[i] = `//${mopidy.host}:${mopidy.port}${images[i]}`; } } digested.push(images[i]); } return digested; }; /** * Format image URLs into a consistent size-based object * We digest all our known image source formats into a universal small,medium,large,huge object * * @param $data mixed * @return Object * */ const formatImages = function (data) { const sizes = { formatted: true, small: null, medium: null, large: null, huge: null, }; if (!data) { return sizes; } // An array of images has been provided if (Array.isArray(data)) { if (data.length <= 0) { return sizes; } for (let i = 0; i < data.length; i++) { const image = data[i]; // Already-formatted if (image.formatted) { return image; // Mopidy image object } if (image.__model__ && image.__model__ == 'Image') { if (image.width < 400) { sizes.small = image.url; } else if (image.width < 800) { sizes.medium = image.url; } else if (image.width < 1000) { sizes.large = image.url; } else { sizes.huge = image.url; } // Mopidy image string } else if (typeof (image) === 'string') { sizes.small = image; // spotify-styled images } else if (image.width !== undefined) { if (image.width < 400) { sizes.small = image.url; } else if (image.width < 800) { sizes.medium = image.url; } else if (image.width < 1000) { sizes.large = image.url; } else { sizes.huge = image.url; } // lastfm-styled images } else if (image.size !== undefined) { switch (image.size) { case 'mega': case 'extralarge': case 'large': sizes.medium = image['#text']; break; case 'medium': case 'small': sizes.small = image['#text']; break; } } } // An object of images has been provided // The Genius avatar object is an example of this } else { if (data.small) sizes.small = data.small.url; if (data.medium) sizes.medium = data.medium.url; if (data.large) sizes.large = data.large.url; if (data.huge) sizes.huge = data.huge.url; } // Inherit images where we haven't been given the appropriate size // Ie small duplicated to tiny, large duplicated to medium, etc if (!sizes.small) { if (sizes.medium) sizes.small = sizes.medium; else if (sizes.large) sizes.small = sizes.large; else if (sizes.huge) sizes.small = sizes.huge; else sizes.small = null; } if (!sizes.medium) { if (sizes.large) sizes.medium = sizes.large; else if (sizes.huge) sizes.medium = sizes.huge; else sizes.medium = sizes.small; } if (!sizes.large) sizes.large = sizes.medium; if (!sizes.huge) sizes.huge = sizes.large; return sizes; }; /** * Format a simple object * This is a shell record containing only the bare essentials. Typically * a tracks' artists/album * * @param data obj * @return obj * */ const formatSimpleObject = function (data) { const simple_object = {}; const fields = [ 'uri', 'name', ]; for (const field of fields) { if (data.hasOwnProperty(field)) { simple_object[field] = data[field]; } } return simple_object; }; /** * Format multiple items * * @param tracks Array * @return Array * */ const formatTracks = function (records = []) { const formatted = []; for (const record of records) { formatted.push(formatTrack(record)); } return formatted; }; const formatAlbums = function (records = []) { const formatted = []; for (const record of records) { formatted.push(formatAlbum(record)); } return formatted; }; const formatArtists = function (records = []) { const formatted = []; for (const record of records) { formatted.push(formatArtist(record)); } return formatted; }; const formatPlaylists = function (records = []) { const formatted = []; for (const record of records) { formatted.push(formatPlaylist(record)); } return formatted; }; const formatUsers = function (records = []) { const formatted = []; for (const record of records) { formatted.push(formatUser(record)); } return formatted; }; const formatCategories = function (records = []) { const formatted = []; for (const record of records) { formatted.push(formatCategory(record)); } return formatted; }; const formatSimpleObjects = function (records = []) { const formatted = []; for (const record of records) { formatted.push(formatSimpleObject(record)); } return formatted; }; /** * Prepare a URI for use in a URL * * Needs to have all special characters encoded to avoid being parsed incorrectly, especially * '/' as this is a URL parameter delimiter * @param {String} uri */ const encodeUri = (uri) => { return encodeURIComponent(uri); }; /** * Rebuild a URI with some ugly-ass handling of encoding. * * Basically the ID part of a Mopidy URI needs to be encoded, but the rest of the URI can't be. * This means we need to break down the URI (decoded) and then reconstruct with an encoded ID. * This is all required because he URI is passed to us *from* a URL which has been encoded for * obvious reasons. * * For example somebackend:track:https://youtube.com/1234 would cause issues with the * https:// section. Our explode-by-':' approach would break this type of URI. * * @param {String} rawUri */ const decodeUri = (rawUri) => { const uri = decodeURIComponent(rawUri); const source = uriSource(uri); const type = uriType(uri); // Escape unreserved characters (RFC 3986) // https://stackoverflow.com/questions/18251399/why-doesnt-encodeuricomponent-encode-single-quotes-apostrophes let id = getFromUri(`${type}id`, uri); id = encodeURIComponent(id).replace(/[!'()*]/g, escape); // Reinstate slashes for the Mopidy-Local structure id = id.replace(/%2F/g, '/'); // Ensure all ':' are uri encoded to lowercase id = id.replace(/%3A/g, '%3a'); let decoded = `${source}:`; switch (source) { case 'dleyna': // Doesn't contain URI type within it's URI schema *facepalm* break; default: decoded += `${type}:`; } decoded += `${id}`; return decoded; }; /** * Format our album objects into a universal format * * @param data obj * @return obj * */ const formatAlbum = function (data) { const album = { type: 'album' }; const fields = [ 'uri', 'in_library', 'provider', 'name', 'type', 'last_modified', 'added_at', 'release_date', 'listeners', 'play_count', 'wiki', 'wiki_publish_date', 'popularity', 'images', 'tracks', 'tracks_total', 'artists', ]; // Nested album object (eg in spotify library) if (data && data.album && isObject(data.album)) { if (data.added_at) { data.album.added_at = data.added_at; } data = data.album; } // Loop fields and import from data for (const field of fields) { if (data.hasOwnProperty(field)) { album[field] = data[field]; } } if (album.images && !album.images.formatted) { album.images = formatImages(album.images); } if (album.tracks) { if (album.tracks.items) { album.tracks = formatTracks(album.tracks.items); } else { album.tracks = formatTracks(album.tracks); } album.tracks_total = album.tracks.length; } if (album.artists) { album.artists = formatSimpleObjects(album.artists); } if (data.last_modified && album.added_at === undefined) { album.added_at = data.last_modified; } else if (data.added_at && album.last_modified === undefined) { album.last_modified = data.added_at; } if (data.date && !album.date) { album.release_date = data.date; } if (data.album_type) { album.type = data.album_type; } if (album.provider === undefined && album.uri !== undefined) { album.provider = uriSource(album.uri); } return album; }; /** * Format our artist objects into a universal format * * @param data obj * @return obj * */ const formatArtist = function (data) { const artist = { type: 'artist' }; const fields = [ 'uri', 'in_library', 'provider', 'mbid', 'name', 'type', 'popularity', 'followers', 'listeners', 'added_at', 'biography', 'biography_link', 'biography_publish_date', 'related_artists', 'albums_uris', 'tracks', ]; // Loop fields and import from data for (const field of fields) { if (data.hasOwnProperty(field)) { artist[field] = data[field]; } } if (data.images && data.images.length > 0) { artist.images = [formatImages(data.images)]; } if (data.followers && data.followers.total !== undefined) { artist.followers = data.followers.total; } if (data.bio) { if (data.bio.content && !artist.biography) { artist.biography = data.bio.content; } if (data.bio.links && data.bio.links.link && data.bio.links.link.href && !artist.biography_link) { artist.biography_link = data.bio.links.link.href; } if (data.bio.published && !artist.biography_publish_date) { artist.biography_publish_date = data.bio.published; } } if (artist.provider === undefined && artist.uri !== undefined) { artist.provider = uriSource(artist.uri); } return artist; }; /** * Format our playlist objects into a universal format * * @param data obj * @return obj * */ const formatPlaylist = function (data) { const playlist = { type: 'playlist' }; const fields = [ 'uri', 'in_library', 'snapshot_id', 'provider', 'type', 'collaborative', 'public', 'name', 'description', 'images', 'popularity', 'followers', 'last_modified', 'can_edit', 'user', 'tracks', 'tracks_total', ]; // Loop fields and import from data for (const field of fields) { if (data.hasOwnProperty(field)) { playlist[field] = data[field]; } } if (playlist.images && !playlist.images.formatted) { playlist.images = formatImages(playlist.images); } if (playlist.tracks) { if (Array.isArray(playlist.tracks)) { playlist.tracks = formatTracks(playlist.tracks); } else if (playlist.tracks.items) { playlist.tracks = formatTracks(playlist.tracks.items); } else { playlist.tracks = null; } } if (playlist.tracks_total === undefined) { playlist.tracks_total = playlist.tracks ? playlist.tracks.length : 0; } if (data.last_modified_date && playlist.last_modified === undefined) { playlist.last_modified = data.last_modified_date; } if (data.followers && data.followers.total !== undefined) { playlist.followers = data.followers.total; } if (data.tracks && data.tracks.total !== undefined) { playlist.tracks_total = data.tracks.total; } if (playlist.last_modified && playlist.added_at === undefined) { playlist.added_at = data.last_modified; } else if (playlist.added_at && playlist.last_modified === undefined) { playlist.last_modified = data.added_at; } if (data.owner && playlist.user === undefined) { playlist.user = { id: data.owner.id, uri: data.owner.uri, name: (data.owner.display_name || data.owner.id), }; } // Spotify upgraded their playlists URI to remove user component (Sept 2018) playlist.uri = upgradeSpotifyPlaylistUri(playlist.uri); if (playlist.provider === undefined && playlist.uri !== undefined) { playlist.provider = uriSource(playlist.uri); } return playlist; }; /** * Format a user objects into a universal format * * @param data obj * @return obj * */ const formatUser = function (data) { const user = { type: 'user' }; const fields = [ 'id', 'in_library', 'uri', 'provider', 'name', 'images', 'followers', 'playlists_uris', 'playlists_total', 'playlists_more', ]; // Loop fields and import from data for (const field of fields) { if (data.hasOwnProperty(field)) { user[field] = data[field]; } } if (!user.images && data.image) { user.images = formatImages(data.image); } else if (!user.images && data.avatar) { user.images = formatImages(data.avatar); } else if (user.images && !user.images.formatted) { user.images = formatImages(user.images); } if (data.followers && data.followers.total !== undefined) { user.followers = data.followers.total; } if (data.realname) { user.name = data.realname; } if (data.display_name && !user.name) { user.name = data.display_name; } if (data.id && !user.name) { user.name = data.id; } if (user.provider === undefined && user.uri !== undefined) { user.provider = uriSource(user.uri); } return user; }; /** * Format tracks into our universal format * * @param data obj * @return obj * */ const formatTrack = function (data) { const track = { type: 'track' }; const fields = [ 'uri', 'in_library', 'tlid', 'provider', 'name', 'images', 'release_date', 'disc_number', 'track_number', 'disc_track', 'duration', 'followers', 'popularity', 'userloved', 'last_modified', 'added_at', 'is_explicit', 'is_local', 'lyrics', 'lyrics_path', 'lyrics_results', 'artists', // Array of simple records 'album', // Array of simple records ]; // Nested track object (eg in spotify playlist) if (data && data.track && isObject(data.track)) { // Copy wrapper's details (if applicable) if (data.added_by) { data.track.added_by = data.added_by; } if (data.added_at) { data.track.added_at = data.added_at; } if (data.tlid) { data.track.tlid = data.tlid; } // And now flatten data = data.track; } // Loop fields and import from data for (const field of fields) { if (data.hasOwnProperty(field)) { track[field] = data[field]; } } if (data.followers && data.followers.total) { track.followers = data.followers.total; } if (track.last_modified && track.added_at === undefined) { track.added_at = track.last_modified; } else if (track.added_at && track.last_modified === undefined) { track.last_modified = track.added_at; } if (data.duration_ms !== undefined) { track.duration = data.duration_ms; } else if (data.length !== undefined) { track.duration = data.length; } if (track.track_number === undefined && data.track_no !== undefined) { track.track_number = data.track_no; } if (track.disc_number === undefined && data.disc_no !== undefined) { track.disc_number = data.disc_no; } if (track.release_date === undefined && data.date !== undefined) { track.release_date = data.date; } if (track.explicit === undefined && data.explicit !== undefined) { track.is_explicit = data.explicit; } // Copy images from albums (if applicable) // TOOD: Identify if we stil need this... if (data.album && data.album.images) { if (track.images === undefined || !track.images.formatted) { track.images = formatImages(data.album.images); } } if (track.provider === undefined && track.uri !== undefined) { track.provider = uriSource(track.uri); } if (track.artists) { track.artists = formatSimpleObjects(track.artists); } if (track.album) { track.album = formatSimpleObject(track.album); } track.disc_track = track.disc_number + (track.track_number / 10); // Gives us a decimal d.t return track; }; /** * Format a snapcast client object into a universal format * * @param data obj * @return obj * */ const formatClient = function (data) { const client = { type: 'client' }; const fields = [ 'id', 'connected', 'name', 'host_name', 'volume', 'mute', 'latency', 'power_on_command', 'power_off_command', ]; for (const field of fields) { if (data.hasOwnProperty(field)) { client[field] = data[field]; } } if (data.config) { if (client.latency === undefined && data.config.latency !== undefined) { client.latency = data.config.latency; } if (!client.name && data.config.name) { client.name = data.config.name; } if (data.config.volume !== undefined) { if (data.config.volume.percent !== undefined) { client.volume = data.config.volume.percent; } if (data.config.volume.muted) { client.mute = data.config.volume.muted; } } } else { if (client.latency === undefined && data.latency !== undefined) { client.latency = data.latency; } if (typeof data.volume === 'object') { if (data.volume.percent) { client.volume = data.volume.percent; } if (data.volume.muted !== undefined) { client.mute = data.volume.muted; } } } if (client.name === undefined && data.host && data.host.name) { client.name = data.host.name; } return client; }; /** * Spotify playlists category * * @param data obj * @return obj * */ const formatCategory = function (data) { const category = { type: 'category' }; const fields = [ 'id', 'uri', 'name', 'playlists_uris', ]; for (const field of fields) { if (data.hasOwnProperty(field)) { category[field] = data[field]; } } if (!category.uri && data.id) { category.uri = `spotify:category:${data.id}`; } if (data.icons) { category.images = formatImages(data.icons); } return category; }; /** * Format a snapcast client object into a universal format * * @param data obj * @return obj * */ const formatGroup = function (data) { const group = { type: 'group' }; const fields = [ 'id', 'name', 'mute', 'stream_id', 'clients_ids', ]; for (const field of fields) { if (data.hasOwnProperty(field)) { group[field] = data[field]; } } if (group.mute === undefined && data.muted !== undefined) { group.mute = data.muted; } return group; }; const collateLibrary = (uris, itemsIndex) => { return compact(uris.map((uri) => itemsIndex[uri])); }; /** * Collate an object with external references into a fully self-contained object * We merge *_uris references (ie tracks_uris) into the main object * * @param object Obj * @param indexes Obj (the relevant core indexes) * @return object Obj * */ const collate = function (obj, indexes = {}) { // First, let's reset this object // This is important because by changing this object, we inadvertently // change the source object (ie the indexed record), which undoes the // efficiencies of a lean index object obj = { ...obj }; // Setup empty arrays for the appropriate reference objects // This helps create a consistent object structure if (obj.artists_uris !== undefined) obj.artists = []; if (obj.albums_uris !== undefined) obj.albums = []; if (obj.tracks_uris !== undefined) obj.tracks = []; if (obj.users_uris !== undefined) obj.users = []; if (obj.playlists_uris !== undefined) obj.playlists = []; if (obj.related_artists_uris !== undefined) obj.related_artists = []; if (obj.clients_ids !== undefined) obj.clients = []; if (obj.items_uris !== undefined) obj.items = []; if (indexes.artists) { if (obj.artists_uris) { for (var uri of obj.artists_uris) { if (indexes.artists[uri]) { obj.artists.push(indexes.artists[uri]); } } } if (obj.related_artists_uris) { for (var uri of obj.related_artists_uris) { if (indexes.artists[uri]) { obj.related_artists.push(indexes.artists[uri]); } } } if (obj.artist_uri) { if (indexes.artists[obj.artist_uri]) { obj.artist = indexes.artists[obj.artist_uri]; } } } if (indexes.albums) { if (obj.albums_uris) { for (var uri of obj.albums_uris) { if (indexes.albums[uri]) { obj.albums.push(indexes.albums[uri]); } } } if (obj.album_uri) { if (indexes.albums[obj.album_uri]) { obj.album = indexes.albums[obj.album_uri]; } } } if (indexes.tracks) { if (obj.tracks_uris) { for (var uri of obj.tracks_uris) { if (indexes.tracks[uri]) { obj.tracks.push(indexes.tracks[uri]); } } } if (obj.track_uri) { if (indexes.tracks[obj.track_uri]) { obj.track = indexes.tracks[obj.track_uri]; } } } if (indexes.users) { if (obj.users_uris) { for (var uri of obj.users_uris) { if (indexes.users[uri]) { obj.users.push(indexes.users[uri]); } } } if (obj.user_uri) { if (indexes.users[obj.user_uri]) { obj.user = indexes.users[obj.user_uri]; } } } if (indexes.playlists) { if (obj.playlists_uris) { for (var uri of obj.playlists_uris) { if (indexes.playlists[uri]) { obj.playlists.push(indexes.playlists[uri]); } } } if (obj.playlist_uri) { if (indexes.playlists[obj.playlist_uri]) { obj.playlist = indexes.playlists[obj.playlist_uri]; } } } if (indexes.clients) { if (obj.clients_ids) { for (const id of obj.clients_ids) { if (indexes.clients[id]) { obj.clients.push(indexes.clients[id]); } } } } if (indexes.items) { if (obj.items_uris) { for (const uri of obj.items_uris) { if (indexes.items[uri]) { obj.items.push(indexes.items[uri]); } } } } return obj; }; /** * Add the array index as our sort_id. * This allows us a source of truth for the original order of items when we have applied * sort rules in the UI. * * @param {Array} array of objects to have sort_id added */ const injectSortId = (array) => { return array.map((item, index) => ({ ...item, sort_id: index })); }; export { toJSON, getTrackIcon, digestMopidyImages, formatImages, formatSimpleObject, formatSimpleObjects, formatAlbum, formatAlbums, formatArtist, formatArtists, formatPlaylist, formatPlaylists, formatUser, formatUsers, formatTrack, formatTracks, formatClient, formatGroup, formatCategory, formatCategories, collate, collateLibrary, injectSortId, encodeUri, decodeUri, }; export default { toJSON, getTrackIcon, digestMopidyImages, formatImages, formatSimpleObject, formatSimpleObjects, formatAlbum, formatAlbums, formatArtist, formatArtists, formatPlaylist, formatPlaylists, formatUser, formatUsers, formatTrack, formatTracks, formatClient, formatGroup, formatCategory, formatCategories, collate, collateLibrary, injectSortId, encodeUri, decodeUri, };