/* == This file is part of Tomahawk Player - <http://tomahawk-player.org> ===
*
* Copyright 2012, Christopher Reichert <creichert07@gmail.com>
* Copyright 2012, Enno Gottschalk <mrmaffen@googlemail.com>
*
* Tomahawk 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.
*
* Tomahawk 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 Tomahawk. If not, see <http://www.gnu.org/licenses/>.
*/
package org.tomahawk.libtomahawk.collection;
import org.jdeferred.Deferred;
import org.jdeferred.Promise;
import org.tomahawk.libtomahawk.database.CollectionDb;
import org.tomahawk.libtomahawk.database.CollectionDbManager;
import org.tomahawk.libtomahawk.database.DatabaseHelper;
import org.tomahawk.libtomahawk.database.UserCollectionDb;
import org.tomahawk.libtomahawk.resolver.Query;
import org.tomahawk.libtomahawk.resolver.UserCollectionStubResolver;
import org.tomahawk.libtomahawk.resolver.models.ScriptResolverTrack;
import org.tomahawk.libtomahawk.utils.ADeferredObject;
import org.tomahawk.tomahawk_android.TomahawkApp;
import org.tomahawk.tomahawk_android.mediaplayers.VLCMediaPlayer;
import org.tomahawk.tomahawk_android.utils.MediaWrapper;
import org.tomahawk.tomahawk_android.utils.PreferenceUtils;
import org.tomahawk.tomahawk_android.utils.WeakReferenceHandler;
import org.videolan.libvlc.Media;
import org.videolan.libvlc.util.AndroidUtil;
import org.videolan.libvlc.util.Extensions;
import android.net.Uri;
import android.os.Environment;
import android.os.Looper;
import android.os.Message;
import android.text.TextUtils;
import android.util.Log;
import android.widget.ImageView;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileFilter;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.Stack;
import java.util.StringTokenizer;
import java.util.concurrent.ConcurrentHashMap;
import de.greenrobot.event.EventBus;
/**
* This class represents a user's local {@link UserCollection}.
*/
public class UserCollection extends DbCollection {
private static final String TAG = UserCollection.class.getSimpleName();
private static final String HAS_SET_DEFAULTDIRS
= "org.tomahawk.tomahawk_android.has_set_defaultdirs";
private static final List<String> TYPE_WHITELIST = Arrays.asList("vfat", "exfat", "sdcardfs",
"fuse", "ntfs", "fat32", "ext3", "ext4", "esdfs");
private static final List<String> TYPE_BLACKLIST = Collections.singletonList("tmpfs");
private static final String[] MOUNT_WHITELIST = {"/mnt", "/Removable", "/storage"};
private static final String[] MOUNT_BLACKLIST = {"/mnt/secure", "/mnt/shell", "/mnt/asec",
"/mnt/obb", "/mnt/media_rw/extSdCard", "/mnt/media_rw/sdcard", "/storage/emulated"};
private static final String[] DEVICE_WHITELIST = {"/dev/block/vold", "/dev/fuse",
"/mnt/media_rw"};
public final static HashSet<String> FOLDER_BLACKLIST;
static {
final String[] folder_blacklist = {"/alarms", "/notifications", "/ringtones",
"/media/alarms", "/media/notifications", "/media/ringtones", "/media/audio/alarms",
"/media/audio/notifications", "/media/audio/ringtones", "/Android/data/"};
FOLDER_BLACKLIST = new HashSet<>();
for (String item : folder_blacklist) {
FOLDER_BLACKLIST
.add(android.os.Environment.getExternalStorageDirectory().getPath() + item);
}
}
private boolean mIsStopping = false;
private boolean mRestart = false;
private Thread mLoadingThread;
private final ConcurrentHashMap<Query, Long> mQueryTimeStamps
= new ConcurrentHashMap<>();
private final ConcurrentHashMap<Artist, Long> mArtistTimeStamps
= new ConcurrentHashMap<>();
private final ConcurrentHashMap<Album, Long> mAlbumTimeStamps
= new ConcurrentHashMap<>();
public UserCollection() {
super(UserCollectionStubResolver.get());
}
@Override
public Promise<String, Throwable, Void> getCollectionId() {
Deferred<String, Throwable, Void> d = new ADeferredObject<>();
return d.resolve(TomahawkApp.PLUGINNAME_USERCOLLECTION);
}
@Override
public void loadIcon(ImageView imageView, boolean grayOut) {
}
public void loadMediaItems(boolean fullScan) {
if (fullScan) {
Log.d(TAG, "Executing full scan. Wiping cache...");
DatabaseHelper.get().removeAllMedias();
}
if (isWorking()) {
if (fullScan) {
// do a clean restart if a scan is ongoing
mRestart = true;
mIsStopping = true;
}
} else {
loadMediaItems();
}
}
private void loadMediaItems() {
if (mLoadingThread == null || mLoadingThread.getState() == Thread.State.TERMINATED) {
mIsStopping = false;
mLoadingThread = new Thread(new GetMediaItemsRunnable());
mLoadingThread.start();
}
}
public void stop() {
mIsStopping = true;
}
public boolean isWorking() {
return mLoadingThread != null &&
mLoadingThread.isAlive() &&
mLoadingThread.getState() != Thread.State.TERMINATED &&
mLoadingThread.getState() != Thread.State.NEW;
}
private class GetMediaItemsRunnable implements Runnable {
@Override
public void run() {
Log.d(TAG, "Scanning for local tracks...");
long time = System.currentTimeMillis();
Set<String> setDefaultDirs = PreferenceUtils.getStringSet(HAS_SET_DEFAULTDIRS);
if (setDefaultDirs == null) {
setDefaultDirs = new HashSet<>();
}
for (String defaultDir : getStorageDirectories()) {
if (!setDefaultDirs.contains(defaultDir)) {
Log.d(TAG, "Default directory added: " + defaultDir);
DatabaseHelper.get().addMediaDir(defaultDir);
setDefaultDirs.add(defaultDir);
}
}
PreferenceUtils.edit().putStringSet(HAS_SET_DEFAULTDIRS, setDefaultDirs).commit();
List<File> mediaDirs = DatabaseHelper.get().getMediaDirs(false);
Stack<File> directories = new Stack<>();
directories.addAll(mediaDirs);
for (File dir : directories) {
Log.d(TAG, "Scanning directory: " + dir);
}
// get all existing media items
HashMap<String, MediaWrapper> existingMedias = DatabaseHelper.get().getMedias();
// list of all added files
HashSet<String> addedLocations = new HashSet<>();
ArrayList<File> mediaToScan = new ArrayList<>();
try {
long listFilesTimeBefore = System.currentTimeMillis();
final HashSet<String> directoriesScanned = new HashSet<>();
// Count total files, and stack them
while (!directories.isEmpty()) {
File dir = directories.pop();
String dirPath = dir.getAbsolutePath();
// Skip some system folders
if (dirPath.startsWith("/proc/") || dirPath.startsWith("/sys/")
|| dirPath.startsWith("/dev/")) {
continue;
}
// Do not scan again if same canonical path
try {
dirPath = dir.getCanonicalPath();
} catch (IOException e) {
Log.e(TAG, "GetMediaItemsRunnable#run() - " + e.getClass() + ": "
+ e.getLocalizedMessage());
}
if (directoriesScanned.contains(dirPath)) {
continue;
} else {
directoriesScanned.add(dirPath);
}
// Do no scan media in .nomedia folders
if (new File(dirPath + "/.nomedia").exists()) {
continue;
}
// Filter the extensions and the folders
try {
File[] f = dir.listFiles(new MediaItemFilter());
if (f != null) {
for (File file : f) {
if (file.isFile()) {
mediaToScan.add(file);
} else if (file.isDirectory()) {
directories.push(file);
}
}
}
} catch (Exception e) {
// listFiles can fail in OutOfMemoryError, go to the next folder
Log.e(TAG, "GetMediaItemsRunnable#run() - " + e.getClass() + ": "
+ e.getLocalizedMessage());
continue;
}
if (mIsStopping) {
Log.d(TAG, "Stopping scan");
return;
}
}
long listFilesTime = System.currentTimeMillis() - listFilesTimeBefore;
int parseCounter = 0;
long parsingTimeBefore = System.currentTimeMillis();
ArrayList<MediaWrapper> mediaWrappers = new ArrayList<>();
// Process the stacked items
for (File file : mediaToScan) {
String fileURI = AndroidUtil.FileToUri(file).toString();
if (existingMedias.containsKey(fileURI)) {
//Log.d(TAG, "File has already been scanned: " + fileURI);
// only add file if it is not already in the list. eg. if a user selects a
// subfolder as well
if (!addedLocations.contains(fileURI)) {
//Log.d(TAG, "File added to processing queue: " + fileURI);
// get existing media item from database
mediaWrappers.add(existingMedias.get(fileURI));
addedLocations.add(fileURI);
}
} else {
// create new media item
final Media media = new Media(VLCMediaPlayer.getLibVlcInstance(),
Uri.parse(fileURI));
media.parse();
parseCounter++;
// skip files with .mod extension and no duration
if ((media.getDuration() == 0 || (media.getTrackCount() != 0
&& TextUtils.isEmpty(media.getTrack(0).codec)))
&& fileURI.endsWith(".mod")) {
Log.d(TAG, "File skipped: " + fileURI);
continue;
}
//Log.d(TAG, "File added to database and processing queue: " + fileURI);
MediaWrapper mw = new MediaWrapper(media);
media.release();
mw.setLastModified(file.lastModified());
mediaWrappers.add(mw);
}
if (mIsStopping) {
Log.d(TAG, "Stopping scan");
return;
}
}
Log.d(TAG, "Listing files took " + listFilesTime + "ms.");
Log.d(TAG, "Scanned " + mediaToScan.size() + " files.");
Log.d(TAG, "Actually parsed " + parseCounter + " files.");
Log.d(TAG,
"Parsing took " + (System.currentTimeMillis() - parsingTimeBefore) + "ms.");
// Add all items to the database
DatabaseHelper.get().addMedias(mediaWrappers);
processMediaWrappers(mediaWrappers);
} finally {
// remove old files & folders from database if storage is mounted
if (!mIsStopping && Environment.getExternalStorageState()
.equals(Environment.MEDIA_MOUNTED)) {
for (String fileURI : addedLocations) {
existingMedias.remove(fileURI);
}
Log.d(TAG, "Removed " + existingMedias.keySet().size()
+ " media items from database");
DatabaseHelper.get().removeMedias(existingMedias.keySet());
}
if (mRestart) {
Log.d(TAG, "Restarting scan");
mRestart = false;
mRestartHandler.sendEmptyMessageDelayed(1, 200);
}
EventBus.getDefault().post(new CollectionManager.UpdatedEvent());
Log.d(TAG, "Scanning process finished in " + (System.currentTimeMillis() - time)
+ "ms");
}
}
private void processMediaWrappers(List<MediaWrapper> mws) {
Log.d(TAG, "Processing " + mws.size() + " media items...");
Map<String, Set<String>> albumArtistsMap = new HashMap<>();
for (MediaWrapper mw : mws) {
if (mw.getType() == MediaWrapper.TYPE_AUDIO) {
String albumKey = mw.getAlbum() != null ? mw.getAlbum().toLowerCase() : "";
if (albumArtistsMap.get(albumKey) == null) {
albumArtistsMap.put(albumKey, new HashSet<String>());
}
albumArtistsMap.get(albumKey).add(mw.getArtist());
}
}
List<ScriptResolverTrack> tracks = new ArrayList<>();
for (MediaWrapper mw : mws) {
if (mw.getType() == MediaWrapper.TYPE_AUDIO) {
ScriptResolverTrack track = new ScriptResolverTrack();
track.album = mw.getAlbum();
track.albumArtist = mw.getAlbumArtist();
track.track = mw.getTitle();
track.artist = mw.getArtist();
track.duration = mw.getLength() / 1000;
track.albumpos = mw.getTrackNumber();
track.url = mw.getLocation();
track.imagePath = mw.getArtworkURL();
track.lastModified = mw.getLastModified();
tracks.add(track);
}
}
CollectionDb db = CollectionDbManager.get().getCollectionDb(getId());
db.wipe();
db.addTracks(tracks);
Log.d(TAG, "Processed " + mws.size() + " media items. " + tracks.size()
+ " tracks have been added to the UserCollection.");
}
}
public ConcurrentHashMap<Query, Long> getQueryTimeStamps() {
return mQueryTimeStamps;
}
public ConcurrentHashMap<Artist, Long> getArtistTimeStamps() {
return mArtistTimeStamps;
}
public ConcurrentHashMap<Album, Long> getAlbumTimeStamps() {
return mAlbumTimeStamps;
}
private final RestartHandler mRestartHandler = new RestartHandler(this);
private static class RestartHandler extends WeakReferenceHandler<UserCollection> {
public RestartHandler(UserCollection userCollection) {
super(Looper.getMainLooper(), userCollection);
}
@Override
public void handleMessage(Message msg) {
if (getReferencedObject() != null) {
getReferencedObject().loadMediaItems();
}
}
}
/**
* Filters all irrelevant files
*/
private static class MediaItemFilter implements FileFilter {
@Override
public boolean accept(File f) {
boolean accepted = false;
if (!f.isHidden()) {
if (f.isDirectory()
&& !FOLDER_BLACKLIST.contains(f.getPath().toLowerCase(Locale.ENGLISH))) {
accepted = true;
} else {
String fileName = f.getName().toLowerCase(Locale.ENGLISH);
int dotIndex = fileName.lastIndexOf(".");
if (dotIndex != -1) {
String fileExt = fileName.substring(dotIndex);
accepted = Extensions.AUDIO.contains(fileExt);
}
}
}
return accepted;
}
}
public static ArrayList<String> getStorageDirectories() {
BufferedReader bufReader = null;
ArrayList<String> list = new ArrayList<>();
list.add(Environment.getExternalStorageDirectory().getPath());
try {
bufReader = new BufferedReader(new FileReader("/proc/mounts"));
String line;
while ((line = bufReader.readLine()) != null) {
StringTokenizer tokens = new StringTokenizer(line, " ");
String device = tokens.nextToken();
String mountpoint = tokens.nextToken();
String type = tokens.nextToken();
// skip if already in list or if type/mountpoint is blacklisted
if (list.contains(mountpoint) || TYPE_BLACKLIST.contains(type)
|| doStringsStartWith(MOUNT_BLACKLIST, mountpoint)) {
continue;
}
// check that device is in whitelist, and either type or mountpoint is in a whitelist
if (doStringsStartWith(DEVICE_WHITELIST, device) && (TYPE_WHITELIST.contains(type)
|| doStringsStartWith(MOUNT_WHITELIST, mountpoint))) {
list.add(mountpoint);
}
}
} catch (IOException e) {
Log.e(TAG, "getStorageDirectories: " + e.getClass() + ": " + e.getLocalizedMessage());
} finally {
if (bufReader != null) {
try {
bufReader.close();
} catch (IOException e) {
Log.e(TAG, "getStorageDirectories: " + e.getClass() + ": "
+ e.getLocalizedMessage());
}
}
}
return list;
}
private static boolean doStringsStartWith(String[] array, String text) {
for (String item : array) {
if (text.startsWith(item)) {
return true;
}
}
return false;
}
public void addLovedArtists(List<Artist> artists, List<Long> lastModifieds) {
UserCollectionDb db = (UserCollectionDb) CollectionDbManager.get().getCollectionDb(
TomahawkApp.PLUGINNAME_USERCOLLECTION);
db.addArtists(artists, lastModifieds);
}
public void removeLoved(Artist artist) {
UserCollectionDb db = (UserCollectionDb) CollectionDbManager.get().getCollectionDb(
TomahawkApp.PLUGINNAME_USERCOLLECTION);
db.remove(artist);
}
public boolean isLoved(Artist artist) {
UserCollectionDb db = (UserCollectionDb) CollectionDbManager.get().getCollectionDb(
TomahawkApp.PLUGINNAME_USERCOLLECTION);
return db.isLoved(artist);
}
public void addLovedAlbums(List<Album> albums, List<Long> lastModifieds) {
UserCollectionDb db = (UserCollectionDb) CollectionDbManager.get().getCollectionDb(
TomahawkApp.PLUGINNAME_USERCOLLECTION);
db.addAlbums(albums, lastModifieds);
}
public void removeLoved(Album album) {
UserCollectionDb db = (UserCollectionDb) CollectionDbManager.get().getCollectionDb(
TomahawkApp.PLUGINNAME_USERCOLLECTION);
db.remove(album);
}
public boolean isLoved(Album album) {
UserCollectionDb db = (UserCollectionDb) CollectionDbManager.get().getCollectionDb(
TomahawkApp.PLUGINNAME_USERCOLLECTION);
return db.isLoved(album);
}
}