/* * 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.art; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.RemoteException; import android.util.Log; import com.fastbootmobile.encore.api.common.HttpGet; import com.fastbootmobile.encore.api.common.RateLimitException; import com.fastbootmobile.encore.api.freebase.FreeBaseClient; import com.fastbootmobile.encore.api.gimages.GoogleImagesClient; import com.fastbootmobile.encore.api.musicbrainz.AlbumInfo; import com.fastbootmobile.encore.api.musicbrainz.MusicBrainzClient; import com.fastbootmobile.encore.framework.PluginsLookup; import com.fastbootmobile.encore.model.Album; import com.fastbootmobile.encore.model.Artist; import com.fastbootmobile.encore.model.BoundEntity; import com.fastbootmobile.encore.model.Playlist; import com.fastbootmobile.encore.model.Song; import com.fastbootmobile.encore.providers.IArtCallback; import com.fastbootmobile.encore.providers.IMusicProvider; import com.fastbootmobile.encore.providers.ProviderAggregator; import com.fastbootmobile.encore.providers.ProviderConnection; import com.fastbootmobile.encore.providers.ProviderIdentifier; import com.fastbootmobile.encore.utils.Utils; import org.json.JSONException; import java.io.IOException; import java.io.InterruptedIOException; import java.util.ArrayList; import java.util.List; /** * Cache downloading and handling album art */ public class AlbumArtCache { private static final String TAG = "AlbumArtCachev2"; private static final AlbumArtCache INSTANCE = new AlbumArtCache(); public static boolean CREATIVE_COMMONS = true; private final List<BoundEntity> mRunningQueries = new ArrayList<>(); /** * The art is not in the cache */ public static final int CACHE_STATUS_UNAVAILABLE = 0; /** * The art is available on the disk */ public static final int CACHE_STATUS_DISK = 1; /** * The art is available in memory */ public static final int CACHE_STATUS_MEMORY = 2; /** * @return Default instance of this class */ public static AlbumArtCache getDefault() { return INSTANCE; } /** * Default constructor */ private AlbumArtCache() { } /** * Empties the image cache */ public void clear() { ImageCache.getDefault().clear(); } /** * Returns whether or not the entity passed in parameter has an album art in the cache * @param ent The entity * @return One of {@link #CACHE_STATUS_DISK}, {@link #CACHE_STATUS_MEMORY}, or * {@link #CACHE_STATUS_UNAVAILABLE} */ public int getCacheStatus(BoundEntity ent) { final String key = getEntityArtKey(ent); final ImageCache cache = ImageCache.getDefault(); if (cache.hasInMemory(key)) { return CACHE_STATUS_MEMORY; } else if (cache.hasOnDisk(key)) { return CACHE_STATUS_DISK; } else { return CACHE_STATUS_UNAVAILABLE; } } /** * Returns the art key that is associated with the provided entity * @param ent The entity from which get the art key * @return The art key of the entity */ public String getEntityArtKey(BoundEntity ent) { return ent.getRef(); } /** * Returns the art associated with the entity * @param ent The entity */ public boolean getArt(final Resources res, BoundEntity ent, final int requestedSize, IAlbumArtCacheListener listener) { final String key = getEntityArtKey(ent); final ImageCache cache = ImageCache.getDefault(); boolean result = false; if (cache.hasInMemory(key) || cache.hasOnDisk(key)) { listener.onArtLoaded(ent, cache.get(res, key, requestedSize)); result = true; } else { if (CREATIVE_COMMONS) { /* * "Copyrighted images to demonstrate functionality, but do not intend to imply * an association with the IP holder". * Yes Google, it's going to ruin content holders to display actual album * art in an app screenshot. Workaround: CC images in special build dedicated * to screenshots. */ getFreeArt(res, ent, listener); } else { try { if (ent instanceof Song) { result = getSongArt(res, (Song) ent, listener); } else if (ent instanceof Artist) { result = getArtistArt(res, (Artist) ent, listener); } else if (ent instanceof Album) { result = getAlbumArt(res, (Album) ent, listener); } else if (ent instanceof Playlist) { result = getPlaylistArt(res, (Playlist) ent, listener); } else { throw new IllegalArgumentException("Entity is of an unknown class!"); } } catch (RemoteException e) { Log.e(TAG, "Remote Exception while trying to get album art", e); } } } return result; } private boolean getFreeArt(final Resources res, final BoundEntity ent, final IAlbumArtCacheListener listener) { new Thread() { public void run() { try { byte[] bytes = HttpGet.getBytes("http://lorempixel.com/600/600/abstract/", "", false); if (bytes != null) { Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); if (bitmap != null) { RecyclingBitmapDrawable rbd = ImageCache.getDefault() .put(res, getEntityArtKey(ent), bitmap); listener.onArtLoaded(ent, rbd); } else { listener.onArtLoaded(ent, null); } } else { listener.onArtLoaded(ent, null); } } catch (IOException | RateLimitException ignore) { listener.onArtLoaded(ent, null); } } }.start(); return true; } private boolean getSongArt(final Resources res, final Song song, final IAlbumArtCacheListener listener) throws RemoteException { // Try to get the art from the provider first final ProviderIdentifier id = song.getProvider(); final IMusicProvider binder = safeGetBinder(id); boolean providerprovides = false; boolean result = false; if (binder != null) { providerprovides = result = binder.getSongArt(song, new IArtCallback.Stub() { @Override public void onArtLoaded(final Bitmap bitmap) throws RemoteException { new Thread() { public void run() { if (bitmap != null) { RecyclingBitmapDrawable rfb = ImageCache.getDefault().put(res, getEntityArtKey(song), bitmap); listener.onArtLoaded(song, rfb); } else { listener.onArtLoaded(song, null); } } }.start(); } }); } if (!providerprovides) { // Provider can't provide an art for this song, get from MusicBrainz final ProviderAggregator aggregator = ProviderAggregator.getDefault(); final Artist artist = song.getArtist() != null ? aggregator.retrieveArtist(song.getArtist(), id) : null; final Album album = song.getAlbum() != null ? aggregator.retrieveAlbum(song.getAlbum(), id) : null; // If we have both the artist and the album name, use that for album art boolean hasAlbum = (album != null && album.getName() != null && !album.getName().isEmpty()); boolean hasArtist = (artist != null && artist.getName() != null && !artist.getName().isEmpty()); if (hasArtist && hasAlbum) { result = getAlbumArtImpl(res, album, artist, listener, song); } else if (hasAlbum && album.getSongsCount() > 0) { result = getAlbumArtImpl(res, album, null, listener, song); } else if (hasArtist) { // TODO: Get any album art from the artist } else { Log.w(TAG, "No album neither artist found for album art"); result = false; } } return result; } private boolean getAlbumArt(final Resources res, final Album album, final IAlbumArtCacheListener listener) throws RemoteException { return getAlbumArtImpl(res, album, null, listener, album); } private boolean getAlbumArtImpl(final Resources res, final Album album, final Artist hintArtist, final IAlbumArtCacheListener listener, final BoundEntity listenerRef) throws RemoteException { // Try to get the art from the provider first final ProviderIdentifier id = album.getProvider(); final IMusicProvider binder = safeGetBinder(id); boolean providerprovides = false; boolean result = false; if (binder != null) { providerprovides = result = binder.getAlbumArt(album, new IArtCallback.Stub() { @Override public void onArtLoaded(final Bitmap bitmap) throws RemoteException { new Thread() { public void run() { if (bitmap != null) { RecyclingBitmapDrawable rcb = ImageCache.getDefault().put(res, getEntityArtKey(album), bitmap); listener.onArtLoaded(album, rcb); } else { listener.onArtLoaded(album, null); } } }.start(); } }); } if (!providerprovides) { String url = null; final String artistRef = (album.getSongsCount() > 0 ? Utils.getMainArtist(album) : null); final String albumName = album.getName(); String artistName = (hintArtist != null ? hintArtist.getName() : null); // If we have the artist, bias the search with it if (artistName == null && artistRef != null) { Artist artist = ProviderAggregator.getDefault().retrieveArtist(artistRef, id); if (artist != null && artist.getName() != null && !artist.getName().isEmpty()) { artistName = artist.getName(); } } if (artistName == null && albumName == null) { // No artist name neither album name, bail out return false; } // Try to get from Google Images try { if (artistName != null && albumName != null) { url = GoogleImagesClient.getImageUrl("album " + artistName + " " + albumName); } else if (artistName == null) { url = GoogleImagesClient.getImageUrl("album " + albumName); } else { url = GoogleImagesClient.getImageUrl("album from " + artistName); } } catch (RateLimitException e) { Log.w(TAG, "Rate limit hit while getting image from Google Images"); } catch (JSONException e) { Log.e(TAG, "JSON Error while getting image from Google Images"); } catch (IOException e) { Log.e(TAG, "IO error while getting image from Google Images"); } if (url == null) { // Get from MusicBrainz as both the provider and Google Images can't provide AlbumInfo[] albums = null; // Query MusicBrainz try { albums = MusicBrainzClient.getAlbum(artistName, albumName); } catch (RateLimitException e) { Log.w(TAG, "Can't get from MusicBrainz, rate limited"); } // If we have results, go and fetch one if (albums != null && albums.length > 0) { AlbumInfo selection = albums[0]; if (albums.length > 1) { // Try to find if we have an album that has the same track count, otherwise use // the first one for (AlbumInfo albumInfo : albums) { if (albumInfo.track_count == album.getSongsCount()) { selection = albumInfo; break; } } } // Get the URL try { url = MusicBrainzClient.getAlbumArtUrl(selection.id); } catch (RateLimitException e) { Log.w(TAG, "Can't get URL from MusicBrainz, rate limited"); } } } // If we have an URL from either image source, download it and pass it back if (url != null) { // Download it try { byte[] imageData = HttpGet.getBytes(url, "", true); BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inMutable = true; Bitmap bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.length, opts); if (bitmap != null) { result = true; RecyclingBitmapDrawable rcb = ImageCache.getDefault().put(res, getEntityArtKey(listenerRef), bitmap); listener.onArtLoaded(listenerRef, rcb); } } catch (IOException e) { Log.e(TAG, "Error while downloading album art"); } catch (RateLimitException e) { Log.e(TAG, "Rate limited while downloading album art"); } } else { ImageCache.getDefault().put(res, getEntityArtKey(listenerRef), (RecyclingBitmapDrawable) null); listener.onArtLoaded(listenerRef, null); result = true; } } return result; } private boolean getArtistArt(final Resources res, final Artist artist, final IAlbumArtCacheListener listener) throws RemoteException { // Try to get the art from the provider first final ProviderIdentifier id = artist.getProvider(); final IMusicProvider binder = safeGetBinder(id); boolean providerprovides = false; boolean result = false; if (binder != null) { providerprovides = result = binder.getArtistArt(artist, new IArtCallback.Stub() { @Override public void onArtLoaded(final Bitmap bitmap) throws RemoteException { new Thread() { public void run() { if (bitmap != null) { RecyclingBitmapDrawable rcb = ImageCache.getDefault().put(res, getEntityArtKey(artist), bitmap); listener.onArtLoaded(artist, rcb); } else { listener.onArtLoaded(artist, null); } } }.start(); } }); } if (!providerprovides) { // Hardcode warning: Image providers tend to return... funny images for empty // names and '<unknown>'. We just don't want images for them then. if (artist.getName() == null || artist.getName().isEmpty() || "<unknown>".equals(artist.getName())) { return false; } // Try to get it first from FreeBase, then from Google Image if none found or error // (Google Images might return some random/unwanted images, so prefer FreeBase first) String url = null; try { url = FreeBaseClient.getArtistImageUrl(artist.getName()); } catch (JSONException e) { Log.e(TAG, "JSON error while getting image from FreeBase"); } catch (RateLimitException e) { Log.w(TAG, "Rate limit hit while getting image from FreeBase"); } catch (IOException e) { Log.e(TAG, "IO error while getting image from FreeBase"); } if (url == null) { try { url = GoogleImagesClient.getImageUrl("Music Band " + artist.getName()); } catch (RateLimitException e) { Log.w(TAG, "Rate limit hit while getting image from Google Images"); } catch (JSONException e) { Log.e(TAG, "JSON Error while getting image from Google Images"); } catch (IOException e) { Log.e(TAG, "IO error while getting image from Google Images (" + e.getMessage() + ")"); } } if (url != null) { try { byte[] imageData = HttpGet.getBytes(url, "", true); BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inMutable = true; Bitmap image = BitmapFactory.decodeByteArray(imageData, 0, imageData.length, opts); if (image != null) { result = true; RecyclingBitmapDrawable rcb = ImageCache.getDefault().put(res, getEntityArtKey(artist), image); listener.onArtLoaded(artist, rcb); } } catch (InterruptedIOException ignore) { } catch (IOException e) { Log.e(TAG, "Failed to download album art image"); } catch (RateLimitException e) { Log.w(TAG, "Rate limited while getting image"); } } else { ImageCache.getDefault().put(res, getEntityArtKey(artist), (RecyclingBitmapDrawable) null); listener.onArtLoaded(artist, null); result = true; } } return result; } private boolean getPlaylistArt(final Resources res, final Playlist playlist, final IAlbumArtCacheListener listener) throws RemoteException { // Try to get the art from the provider first final ProviderIdentifier id = playlist.getProvider(); final IMusicProvider binder = safeGetBinder(id); boolean providerprovides = false; boolean result = false; if (binder != null) { providerprovides = binder.getPlaylistArt(playlist, new IArtCallback.Stub() { @Override public void onArtLoaded(final Bitmap bitmap) throws RemoteException { new Thread() { public void run() { if (bitmap != null) { RecyclingBitmapDrawable rcb = ImageCache.getDefault().put(res, getEntityArtKey(playlist), bitmap); listener.onArtLoaded(playlist, rcb); } else { listener.onArtLoaded(playlist, null); } } }.start(); } }); result = providerprovides; } if (!providerprovides) { // Build our own art final PlaylistArtBuilder builder = new PlaylistArtBuilder(); builder.start(res, playlist, new IArtCallback.Stub() { @Override public void onArtLoaded(final Bitmap bitmap) throws RemoteException { if (bitmap == null) return; new Thread() { public void run() { RecyclingBitmapDrawable rcb = ImageCache.getDefault().put(res, getEntityArtKey(playlist), bitmap); listener.onArtLoaded(playlist, rcb); builder.freeMemory(); } }.start(); } }); result = true; } return result; } private IMusicProvider safeGetBinder(final ProviderIdentifier id) { final ProviderConnection conn = PluginsLookup.getDefault().getProvider(id); if (conn != null) { return conn.getBinder(); } else { return null; } } /** * Returns whether or not a query is currently running for the provided song * @param song The song for which we need the album art * @return true if a request is currently running, false if nothing is currently looking for * that song's art */ public boolean isQueryRunning(final BoundEntity song) { synchronized (mRunningQueries) { return mRunningQueries.contains(song); } } /** * Notifies an async task has started processing the art for the provided entity * @param song The song for which we need the album art */ public void notifyQueryRunning(final BoundEntity song) { synchronized (mRunningQueries) { mRunningQueries.add(song); } } /** * Notifies that the async task that started processing the art for the entity has done * @param song The song for which we need the album art */ public void notifyQueryStopped(final BoundEntity song) { synchronized (mRunningQueries) { mRunningQueries.remove(song); } } public interface IAlbumArtCacheListener { void onArtLoaded(BoundEntity ent, RecyclingBitmapDrawable result); } }