/* * Copyright (C) 2014 Fastboot Mobile, LLC. * * This program is free software; you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation; either version 3 of * the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See * the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with this program; * if not, see <http://www.gnu.org/licenses>. */ package com.fastbootmobile.encore.api.echonest; import android.os.SystemClock; import android.util.Log; import com.echonest.api.v4.Artist; import com.echonest.api.v4.Biography; import com.echonest.api.v4.CatalogUpdater; import com.echonest.api.v4.DynamicPlaylistParams; import com.echonest.api.v4.DynamicPlaylistSession; import com.echonest.api.v4.EchoNestAPI; import com.echonest.api.v4.EchoNestException; import com.echonest.api.v4.GeneralCatalog; import com.echonest.api.v4.IdentifySongParams; import com.echonest.api.v4.Params; import com.echonest.api.v4.PlaylistParams; import com.echonest.api.v4.SongCatalogItem; import com.fastbootmobile.encore.api.common.APIKeys; import com.fastbootmobile.encore.model.Album; import com.fastbootmobile.encore.model.Playlist; import com.fastbootmobile.encore.model.Song; import com.fastbootmobile.encore.providers.ProviderAggregator; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Random; /** * EchoNest Glue class between jEN and the data we use in OmniMusic * TODO: Cache artist info on disk */ public class EchoNest { private static final String TAG = "EchoNest"; private static final boolean DEBUG = false; private EchoNestAPI mEchoNest; private static final Map<String, Artist> sArtistSearchCache = new HashMap<>(); private static final Map<Artist, Biography> sArtistBiographyCache = new HashMap<>(); private static final Map<Artist, Map<String, String>> sArtistUrlsCache = new HashMap<>(); private static final Map<Artist, List<Artist>> sArtistSimilarCache = new HashMap<>(); private static final Map<Artist, Map<String, String>> sArtistForeignIDs = new HashMap<>(); /** * Initializes an EchoNest API client with the EchoNest API key */ public EchoNest() { mEchoNest = new EchoNestAPI(APIKeys.KEY_ECHONEST); mEchoNest.setTraceRecvs(DEBUG); mEchoNest.setTraceSends(DEBUG); } /** * @return The {@link com.echonest.api.v4.EchoNestAPI} handle */ public EchoNestAPI getApi() { return mEchoNest; } /** * Returns whether or not the provided artist name is in cache for searchArtistByName * @param name The artist name * @return True if the artist is in cache and searchArtistByName won't do any network * query, false otherwise */ public boolean hasArtistInCache(String name) { synchronized (sArtistSearchCache) { return sArtistSearchCache.containsKey(name); } } /** * Searches an EchoNest {@link com.echonest.api.v4.Artist} by name * @param name The artist name * @return An {@link com.echonest.api.v4.Artist} or null if none found * @throws EchoNestException In case of error (not found, network error, etc). */ public Artist searchArtistByName(String name) throws EchoNestException { // First look in the cache Artist result; synchronized (sArtistSearchCache) { result = sArtistSearchCache.get(name); } if (result == null) { // We don't have this artist cached, so let's look it up on EchoNest Params p = new Params(); p.add("name", name); p.add("results", 1); List<Artist> results = mEchoNest.searchArtists(p); if (results.size() > 0) { result = results.get(0); synchronized (sArtistSearchCache) { sArtistSearchCache.put(name, result); } } } return result; } /** * Returns whether or not the biography for the provided artist exists in the cache (ie. * a call to {@link #getArtistBiography(com.echonest.api.v4.Artist)} won't do any network * operation. * @param artist The artist for which we want the biography * @return True if the biography is cached, false otherwise */ public boolean hasArtistBiographyCached(Artist artist) { synchronized (sArtistBiographyCache) { return sArtistBiographyCache.containsKey(artist); } } /** * Fetches and return the artist biography for the provided artist. This method is doing * network operations if the biography is not already cached. * @param artist The artist for which we want the biography * @return A {@link com.echonest.api.v4.Biography}, or null if none available * @throws EchoNestException */ public Biography getArtistBiography(Artist artist) throws EchoNestException { Biography result; // First, look in the cache synchronized (sArtistBiographyCache) { result = sArtistBiographyCache.get(artist); } if (result == null) { List<Biography> results = artist.getBiographies(0, 10); // We prefer wikipedia, and otherwise the longest one for (Biography bio : results) { if (bio.getSite().equals("wikipedia")) { result = bio; break; } else if (result == null || result.getText().length() < bio.getText().length()) { result = bio; } } // Cache it synchronized (sArtistBiographyCache) { sArtistBiographyCache.put(artist, result); } } return result; } /** * Returns a map of Artist URLs (artists websites, etc). for the provided artists * @param artist The artist for which we want URLs * @return A map of [Site Name, Site URL] * @throws EchoNestException */ public Map<String, String> getArtistUrls(Artist artist) throws EchoNestException { Map<String, String> result; synchronized (sArtistUrlsCache) { result = sArtistUrlsCache.get(artist); } if (result == null) { result = artist.getUrls(); synchronized (sArtistUrlsCache) { sArtistUrlsCache.put(artist, result); } } return result; } /** * Returns whether or not the similar artists for the provided artists are in cache (ie. a call * to {@link #getArtistSimilar(com.echonest.api.v4.Artist)} won't do any network operation) * @param artist The artist for which get similar results * @return True if in cache, false otherwise */ public boolean hasArtistSimilarCached(Artist artist) { synchronized (sArtistSimilarCache) { return sArtistSimilarCache.containsKey(artist); } } /** * Returns a list of similar artists for the provided artist * @param artist The artist for which get similar results * @return A list of artists similar to the artist provided * @throws EchoNestException */ public List<Artist> getArtistSimilar(Artist artist) throws EchoNestException { List<Artist> result; synchronized (sArtistSimilarCache) { result = sArtistSimilarCache.get(artist); } if (result == null) { // Get similar artists result = artist.getSimilar(6); synchronized (sArtistSimilarCache) { sArtistSimilarCache.put(artist, result); } // Put rosetta stone IDs for each for linking List<String> rosettaPrefixes = ProviderAggregator.getDefault().getRosettaStonePrefix(); for (Artist similar : result) { Map<String, String> linksMap; synchronized (sArtistForeignIDs) { linksMap = sArtistForeignIDs.get(similar); if (linksMap == null) { linksMap = new HashMap<>(); sArtistForeignIDs.put(similar, linksMap); } } for (String prefix : rosettaPrefixes) { try { String rosettaLink = similar.getForeignID(prefix); synchronized (sArtistForeignIDs) { linksMap.put(prefix, rosettaLink); } } catch (Exception ignore) { } } } } return result; } /** * Returns the Rosetta ID for the provided artist and the provided rosetta prefix. This only * works for artists retrieved in getArtistSimilar. * * @param artist The artist for which get the foreign ID * @param prefix The rosetta prefix (e.g. "spotify") * @return The rosetta ID of the artist */ public String getArtistForeignID(Artist artist, String prefix) { synchronized (sArtistForeignIDs) { Map<String, String> rosettaMap = sArtistForeignIDs.get(artist); if (rosettaMap != null) { return rosettaMap.get(prefix); } } return null; } /** * Creates a dynamic playlist based on user preferences. All parameters may be used, however * depending on the type parameter, at least one parameter has to be set: * - If type is set to "artist-description", either mood, style or both must be set. Catalog * might be used to further personalize the generated bucket. * - If type is set to "catalog-radio", seedCatalog must be set. Mood and style might be * used to further personalize the generated bucket. * * @param type One of: artist-description, catalog-radio * @param seedCatalog The ID of the catalog (taste profile) to use. May be null if type is * set to artist-description * @param mood An array of moods. May be null if type is set to catalog-radio * @param style An array of styles. May be null if type is set to catalog-radio * @return The created playlist session * @throws EchoNestException */ public DynamicPlaylistSession createDynamicPlaylist(String type, String seedCatalog, String[] mood, String[] style) throws EchoNestException { // Some checks if (!"artist-description".equals(type) && !"catalog-radio".equals(type)) { throw new EchoNestException(-1, "Only 'artist-description' and 'catalog-radio' type " + "are supported"); } if ((seedCatalog == null && mood == null && style == null) || ("artist-description".equals(type) && (mood == null || mood.length <= 0) && (style == null || style.length <= 0)) || ("catalog-radio".equals(type) && (seedCatalog == null || seedCatalog.isEmpty()))) { throw new EchoNestException(-1, "seedCatalog, mood or style must be filled depending " + "on the type"); } // Everything looks plausible, let's craft the query DynamicPlaylistParams p = new DynamicPlaylistParams(); p.add("type", type); if (seedCatalog != null) { p.addSeedCatalog(seedCatalog); } if (mood != null) { for (String m : mood) { p.addMood(m); } } if (style != null) { for (String s : style) { p.addStyle(s); } } String prefix = ProviderAggregator.getDefault().getPreferredRosettaStonePrefix(); if (prefix != null) { p.addIDSpace(prefix); p.includeTracks(); } // Send the query and get the session ID DynamicPlaylistSession session = mEchoNest.createDynamicPlaylist(p); Log.e(TAG, "Session ID: " + session.getSessionID()); return session; } public List<String> createStaticPlaylist(String type, String seedCatalog, String[] style, String[] mood, float adventurous, String[] songTypes, float speechiness, float energy, float familiar) throws EchoNestException { // Some checks if (!"artist-description".equals(type) && !"catalog-radio".equals(type)) { throw new EchoNestException(-1, "Only 'artist-description' and 'catalog-radio' type " + "are supported"); } if ((seedCatalog == null && mood == null && style == null) || ("artist-description".equals(type) && (mood == null || mood.length <= 0) && (style == null || style.length <= 0)) || ("catalog-radio".equals(type) && (seedCatalog == null || seedCatalog.isEmpty()))) { throw new EchoNestException(-1, "seedCatalog, mood or style must be filled depending " + "on the type"); } // Everything looks plausible, let's craft the query PlaylistParams p = new PlaylistParams(); p.setResults(100); p.add("type", type); if (seedCatalog != null) { p.addSeedCatalog(seedCatalog); } if (mood != null) { for (String m : mood) { p.addMood(m); } } if (style != null) { for (String s : style) { p.addStyle(s); } } if (songTypes != null && songTypes.length > 0) { for (String songType : songTypes) { switch (songType) { case "christmas": p.addSongType(com.echonest.api.v4.Song.SongType.christmas, com.echonest.api.v4.Song.SongTypeFlag.True); break; case "live": p.addSongType(com.echonest.api.v4.Song.SongType.live, com.echonest.api.v4.Song.SongTypeFlag.True); break; case "studio": p.addSongType(com.echonest.api.v4.Song.SongType.studio, com.echonest.api.v4.Song.SongTypeFlag.True); break; case "acoustic": p.add("song_type", songType + ":true"); break; case "electric": p.add("song_type", songType + ":true"); break; default: Log.e(TAG, "Unrecognized song type: " + songType); break; } } } if (adventurous >= 0) { p.setAdventurousness(adventurous); } if (speechiness >= 0) { p.add("target_speechiness", speechiness); } if (energy >= 0) { p.add("target_energy", energy); } if (familiar >= 0) { p.add("target_artist_familiarity", familiar); } String prefix = ProviderAggregator.getDefault().getPreferredRosettaStonePrefix(); if (prefix != null) { Log.d(TAG, "Using rosetta prefix " + prefix); p.addIDSpace(prefix); p.includeTracks(); } p.setLimit(true); // Send the query and get the playlist com.echonest.api.v4.Playlist playlist = mEchoNest.createStaticPlaylist(p); List<String> songs = new ArrayList<>(); List<com.echonest.api.v4.Song> enSongs = playlist.getSongs(); for (com.echonest.api.v4.Song song : enSongs) { songs.add(song.getTrack(prefix).getForeignID()); } return songs; } /** * Creates a temporary taste profile. * * The EchoNest API is limited to 1,000 profiles for a non-commercial API key, so we create a * temporary profile, upload locally tracked data, do what we want with it, then remove it. * Our limitation then is that less than 1,000 people are creating a temporary bucket. * * Note that this method isn't limited to rosetta-enabled providers, as EchoNest supports * adding tracks from name instead of rosetta IDs or ENID. Tracks information will be added * by name and exclusive match against the local library will be done if no cloud provider is * available for playback. * * @return The temporary profile name */ public GeneralCatalog createTemporaryTasteProfile() throws EchoNestException { // Generate some random name for the profile String profileName = Long.toString(SystemClock.uptimeMillis()) + new Random().nextLong() + System.currentTimeMillis(); GeneralCatalog profile = mEchoNest.createGeneralCatalog(profileName); // Upload all playlists tracks CatalogUpdater updater = new CatalogUpdater(); final ProviderAggregator aggregator = ProviderAggregator.getDefault(); final List<Playlist> playlists = aggregator.getAllPlaylists(); // For each playlist int tracksCount = 0; List<String> knownTracks = new ArrayList<>(); for (Playlist p : playlists) { // For each song of each playlist Iterator<String> songIt = p.songs(); while (songIt.hasNext()) { String songRef = songIt.next(); if (knownTracks.contains(songRef)) { continue; } else { knownTracks.add(songRef); } // TODO: Add a way to know if references are rosetta IDs to avoid lookups from EN Song song = aggregator.retrieveSong(songRef, p.getProvider()); if (song != null) { // We have the song info, add it to our profile SongCatalogItem item = new SongCatalogItem(songRef); // If we have artist info, add it com.fastbootmobile.encore.model.Artist artist = aggregator.retrieveArtist(song.getArtist(), p.getProvider()); if (artist != null && artist.isLoaded() && artist.getName() != null && !artist.getName().trim().isEmpty()) { item.setArtistName(artist.getName()); } // If we have album info, add it Album album = aggregator.retrieveAlbum(song.getAlbum(), p.getProvider()); if (album != null && album.isLoaded() && album.getName() != null) { item.setRelease(album.getName()); } // Add other regular info item.setSongName(song.getTitle()); item.setUrl(songRef); updater.update(item); // TODO: play count, favorite, rating tracksCount++; } } } // Push the data to EchoNest if (DEBUG) { Log.d(TAG, "Pushing " + tracksCount + " tracks data to EchoNest Profile"); } long startTime = SystemClock.uptimeMillis(); String ticket = profile.update(updater); if (profile.waitForUpdates(ticket, 10000)) { Log.i(TAG, "Profile update completed in " + (SystemClock.uptimeMillis() - startTime) + " ms"); } else { Log.w(TAG, "Profile update not done after 10 seconds! Bucket data might be wrong " + "until it fully completes!"); } return profile; } /** * Identifies a song via the EchoNest/EchoPrint API. You must generate an EchoPrint code first * from the audio input using {@link com.fastbootmobile.encore.framework.EchoPrint} class. * @param codePrint The output EchoPrint generated code * @return A list of matches * @throws EchoNestException */ public List<com.echonest.api.v4.Song> identifySong(String codePrint) throws EchoNestException { IdentifySongParams params = new IdentifySongParams(); params.setCode(codePrint); params.includeAudioSummary(); return mEchoNest.identifySongs(params); } }