/*
* 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);
}
}