/*
* Jajuk
* Copyright (C) The Jajuk Team
* http://jajuk.info
*
* 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 2
* of the License, or 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, write to the Free Software
* Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
*
*/
package org.jajuk.base;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import org.apache.commons.collections.CollectionUtils;
import org.jajuk.events.JajukEvent;
import org.jajuk.events.JajukEvents;
import org.jajuk.events.ObservationManager;
import org.jajuk.services.bookmark.History;
import org.jajuk.services.bookmark.HistoryItem;
import org.jajuk.services.players.QueueModel;
import org.jajuk.util.Conf;
import org.jajuk.util.Const;
import org.jajuk.util.MD5Processor;
import org.jajuk.util.Messages;
import org.jajuk.util.ReadOnlyIterator;
import org.jajuk.util.UtilSystem;
import org.jajuk.util.error.CannotRenameException;
import org.jajuk.util.error.JajukException;
import org.jajuk.util.filters.JajukPredicates;
import org.jajuk.util.log.Log;
/**
* Convenient class to manage files.
*/
public final class FileManager extends ItemManager {
/** Best of files. */
private final List<File> alBestofFiles = new ArrayList<File>(20);
/** Self instance. */
private static FileManager singleton = new FileManager();
/**
* Played recently filtering predicate
* <br/>Applies on HistoryItem collections
*/
protected static final int CONSIDERE_AS_RECENTLY_PLAYED_DAYS = 150;
/** File comparator based on rate. */
private final Comparator<File> rateComparator = new Comparator<File>() {
@Override
public int compare(File file1, File file2) {
long lRate1 = file1.getTrack().getRate();
long lRate2 = file2.getTrack().getRate();
if (lRate1 == lRate2) {
return 0;
} else if (lRate1 < lRate2) {
return 1;
} else {
return -1;
}
}
};
/**
* No constructor available, only static access.
*/
private FileManager() {
super();
// ---register properties---
// ID
registerProperty(new PropertyMetaInformation(Const.XML_ID, false, true, false, false, false,
String.class, null));
// Name
registerProperty(new PropertyMetaInformation(Const.XML_NAME, false, true, true, true, false,
String.class, null));
// Directory
registerProperty(new PropertyMetaInformation(Const.XML_DIRECTORY, false, true, true, false,
true, String.class, null));
// Track
registerProperty(new PropertyMetaInformation(Const.XML_TRACK, false, true, true, false, false,
String.class, null));
// Size
registerProperty(new PropertyMetaInformation(Const.XML_SIZE, false, true, true, false, false,
Long.class, null));
// Quality
registerProperty(new PropertyMetaInformation(Const.XML_QUALITY, false, true, true, false,
false, Long.class, 0));
// Date
registerProperty(new PropertyMetaInformation(Const.XML_FILE_DATE, false, false, true, false,
false, Date.class, new Date()));
}
/**
* Gets the instance.
*
* @return singleton
*/
public static FileManager getInstance() {
return singleton;
}
/**
* Register an File with a known id.
*
* @param sId
* @param sName
* @param directory
* @param track
* @param lSize
* @param lQuality
*
* @return the file
*/
public File registerFile(String sId, String sName, Directory directory, Track track, long lSize,
long lQuality) {
lock.writeLock().lock();
try {
File file = getFileByID(sId);
if (file == null) {
file = new File(sId, sName, directory, track, lSize, lQuality);
registerItem(file);
if (directory.getDevice().isRefreshing() && Log.isDebugEnabled()) {
Log.debug("registrated new file: " + file);
}
} else if (!file.getName().equals(sName)) {
// If file already exist and the track has changed, make changes
// Set name again because under Windows, the file name case
// could have changed but we keep the same file object
file.setName(sName);
}
// Add file to track
track.addFile(file);
return file;
} finally {
lock.writeLock().unlock();
}
}
/**
* Register an File without known id.
*
* @param sName
* @param directory
* @param track
* @param lSize
* @param lQuality
*
* @return the file
*/
public File registerFile(String sName, Directory directory, Track track, long lSize, long lQuality) {
String sId = createID(sName);
return registerFile(sId, sName, directory, track, lSize, lQuality);
}
/**
* Get file hashcode (ID).
*
* @param sName
* @param dir
*
* @return file ID
*/
protected static String createID(String sName, Directory dir) {
String id = null;
// Under windows, all files/directories with different cases should get
// the same ID
if (UtilSystem.isUnderWindows()) {
id = MD5Processor.hash(new StringBuilder(dir.getDevice().getName())
.append(dir.getRelativePath().toLowerCase(Locale.getDefault()))
.append(sName.toLowerCase(Locale.getDefault())).toString());
} else {
id = MD5Processor.hash(new StringBuilder(dir.getDevice().getName())
.append(dir.getRelativePath()).append(sName).toString());
}
return id;
}
/**
* Change a file name.
*
* @param fileOld
* @param sNewName
*
* @return new file
*
* @throws JajukException the jajuk exception
*/
public File changeFileName(org.jajuk.base.File fileOld, String sNewName) throws JajukException {
lock.writeLock().lock();
try {
// check given name is different
if (fileOld.getName().equals(sNewName)) {
return fileOld;
}
// check if this file still exists
if (!fileOld.getFIO().exists()) {
throw new CannotRenameException(135);
}
// check that the file is not currently played
if (QueueModel.getCurrentItem() != null
&& QueueModel.getCurrentItem().getFile().equals(fileOld) && QueueModel.isPlayingTrack()) {
throw new CannotRenameException(172);
}
java.io.File fileNew = new java.io.File(fileOld.getFIO().getParentFile().getAbsolutePath()
+ java.io.File.separator + sNewName);
// check file name and extension
if (!(UtilSystem.getExtension(fileNew).equals(UtilSystem.getExtension(fileOld.getFIO())))) {
// no extension change
throw new CannotRenameException(134);
}
// check if destination file already exists (under windows, file.exists
// return true even with different case so we test file name is different)
if (!fileNew.getName().equalsIgnoreCase(fileOld.getName()) && fileNew.exists()) {
throw new CannotRenameException(134);
}
// try to rename file on disk
try {
if (!fileOld.getFIO().renameTo(fileNew)) {
throw new CannotRenameException(134);
}
} catch (Exception e) {
throw new CannotRenameException(134, e);
}
// OK, remove old file and register this new file
// Compute file ID
Directory dir = fileOld.getDirectory();
String sNewId = createID(sNewName, dir);
// create a new file (with own fio and sAbs)
Track track = fileOld.getTrack();
// Remove old file from associated track
track.removeFile(fileOld);
org.jajuk.base.File fNew = new File(sNewId, sNewName, fileOld.getDirectory(), track,
fileOld.getSize(), fileOld.getQuality());
// transfer all properties and reset id and name
fNew.setProperties(fileOld.getProperties());
fNew.setProperty(Const.XML_ID, sNewId); // reset new id and name
fNew.setProperty(Const.XML_NAME, sNewName); // reset new id and name
removeFile(fileOld);
registerItem(fNew);
track.addFile(fNew);
// notify everybody for the file change
Properties properties = new Properties();
properties.put(Const.DETAIL_OLD, fileOld);
properties.put(Const.DETAIL_NEW, fNew);
// Notify interested items (like history manager)
ObservationManager.notifySync(new JajukEvent(JajukEvents.FILE_NAME_CHANGED, properties));
return fNew;
} finally {
lock.writeLock().unlock();
}
}
/**
* Change a file directory and actually move the old file to the new directory.
*
* @param old old file
* @param newDir new dir
*
* @return new file or null if an error occurs
* @throws JajukException the jajuk exception
*/
public File changeFileDirectory(File old, Directory newDir) throws JajukException {
lock.writeLock().lock();
try {
// recalculate file ID
String sNewId = FileManager.createID(old.getName(), newDir);
Track track = old.getTrack();
// create a new file (with own fio and sAbs)
File fNew = new File(sNewId, old.getName(), newDir, track, old.getSize(), old.getQuality());
// Transfer all properties (including id), then set right id and directory
fNew.setProperties(old.getProperties());
fNew.setProperty(Const.XML_ID, sNewId);
fNew.setProperty(Const.XML_DIRECTORY, newDir.getID());
// Real IO move
try {
if (!old.getFIO().renameTo(fNew.getFIO())) {
throw new CannotRenameException(134);
}
} catch (Exception e) {
throw new CannotRenameException(134, e);
}
// OK, remove old file and register this new file
removeFile(old);
registerItem(fNew);
track.addFile(fNew);
return fNew;
} finally {
lock.writeLock().unlock();
}
}
/**
* Clean all references for the given device.
*
* @param sId :
* Device id
*/
public void cleanDevice(String sId) {
lock.writeLock().lock();
try {
for (File file : getFiles()) {
if (file.getDirectory() == null || file.getDirectory().getDevice().getID().equals(sId)) {
removeItem(file);
}
}
} finally {
lock.writeLock().unlock();
}
}
/**
* Remove a file reference.
*
* @param file
*/
public void removeFile(File file) {
lock.writeLock().lock();
try {
// We need to remove the file from the track !
TrackManager.getInstance().removeFile(file);
removeItem(file);
} finally {
lock.writeLock().unlock();
}
}
/**
* Return file by full path.
*
* @param sPath :
* full path
*
* @return file or null if given path is not known
*/
File getFileByPath(String sPath) {
lock.readLock().lock();
try {
File fOut = null;
java.io.File fToCompare = new java.io.File(sPath);
ReadOnlyIterator<File> it = getFilesIterator();
while (it.hasNext()) {
File file = it.next();
// we compare io files and not paths
// to avoid dealing with path name issues
if (file.getFIO().equals(fToCompare)) {
fOut = file;
break;
}
}
// Fix #1717 (Cannot load some playlists) : if the file is not found, second chance ignoring the case
// This can happen under Unix when using an SMB drive
if (fOut == null) {
it = getFilesIterator();
while (it.hasNext()) {
File file = it.next();
if (file.getFIO().getAbsolutePath().equalsIgnoreCase(fToCompare.getAbsolutePath())) {
fOut = file;
break;
}
}
}
return fOut;
} finally {
lock.readLock().unlock();
}
}
/**
* Gets the ready files.
*
* @return All accessible files of the collection
*/
public List<File> getReadyFiles() {
List<File> files = getFiles();
CollectionUtils.filter(files, new JajukPredicates.ReadyFilePredicate());
return files;
}
/**
* Return a shuffle mounted and unbaned file from the entire collection or
* null if none available using these criterias.
*
* @return the file
*/
public File getShuffleFile() {
List<File> alEligibleFiles = getReadyFiles();
// filter banned files
CollectionUtils.filter(alEligibleFiles, new JajukPredicates.BannedFilePredicate());
if (alEligibleFiles.size() > 0) {
int index = UtilSystem.getRandom().nextInt(alEligibleFiles.size() - 1);
return alEligibleFiles.get(index);
} else {
return null;
}
}
/**
* Return an ordered playlist with the entire accessible shuffle collection.
*
* @return The entire accessible shuffle collection (can return a void
* collection)
*/
public List<File> getGlobalShufflePlaylist() {
List<File> alEligibleFiles = getReadyFiles();
// filter banned files
CollectionUtils.filter(alEligibleFiles, new JajukPredicates.BannedFilePredicate());
// We filter recently played tracks to improve the quality of the randomness
filterRecentlyPlayedTracks(alEligibleFiles);
// shuffle
Collections.shuffle(alEligibleFiles, UtilSystem.getRandom());
// song level, just shuffle full collection
if (Conf.getString(Const.CONF_GLOBAL_RANDOM_MODE).equals(Const.MODE_TRACK)) {
return alEligibleFiles;
}
// (not shuffle) Album / album
else if (Conf.getString(Const.CONF_GLOBAL_RANDOM_MODE).equals(Const.MODE_ALBUM2)) {
final List<Album> albums = AlbumManager.getInstance().getAlbums();
Collections.shuffle(albums, UtilSystem.getRandom());
// We need an index (bench: 45* faster)
final Map<Album, Integer> index = new HashMap<Album, Integer>();
for (Album album : albums) {
index.put(album, albums.indexOf(album));
}
Collections.sort(alEligibleFiles, new Comparator<File>() {
@Override
public int compare(File f1, File f2) {
if (f1.getTrack().getAlbum().equals(f2.getTrack().getAlbum())) {
int comp = (int) (f1.getTrack().getOrder() - f2.getTrack().getOrder());
if (comp == 0) {
// If no track number is given, try to sort by
// filename than can contain the track
return f1.getName().compareTo(f2.getName());
}
return comp;
}
return index.get(f1.getTrack().getAlbum()) - index.get(f2.getTrack().getAlbum());
}
});
return alEligibleFiles;
// else return shuffle albums
} else {
return getShuffledFilesByAlbum(alEligibleFiles);
}
}
/**
* Filter files to keep only files not played recently.
* <br/>It contributes to improve the shuffling experience by avoiding playing the same track twice
* in a small period of time. It can't be implemented using a predicate because we want to break ASAP,
* when the max time is reached.
* <br/>Note however that we stop filtering when we reach a too small size of remaining files.
* @param files files to filter
*/
protected void filterRecentlyPlayedTracks(List<File> files) {
long now = new Date().getTime();
for (HistoryItem item : History.getInstance().getItems()) {
int trackAgeDays = (int) ((now - item.getDate()) / Const.MILLISECONDS_IN_A_DAY);
if (trackAgeDays < CONSIDERE_AS_RECENTLY_PLAYED_DAYS) {
if (files.size() > Const.NB_TRACKS_ON_ACTION) {
File file = FileManager.getInstance().getFileByID(item.getFileId());
if (file != null && file.getTrack() != null) {
// Remove this item if it exist in the list (otherwise, List does nothing)
files.remove(file);
}
} else {
//We reach the floor of too few tracks so we stop to filter by date
break;
}
} else {
// We reach the non-recently played area of the history, we can leave
break;
}
}
}
/**
* Return a shuffle mounted file from the novelties.
*
* @return the novelty file
*/
public File getNoveltyFile() {
List<File> alEligibleFiles = getGlobalNoveltiesPlaylist();
return alEligibleFiles.get((int) (Math.random() * alEligibleFiles.size()));
}
/**
* Return a shuffled playlist with the entire accessible novelties collection.
*
* @return The entire accessible novelties collection (can return a void
* collection)
*/
public List<org.jajuk.base.File> getGlobalNoveltiesPlaylist() {
return getGlobalNoveltiesPlaylist(true);
}
/**
* Return an ordered playlist with the accessible novelties collection The
* number of returned items is limited to NB_TRACKS_ON_ACTION for performance
* reasons.
*
* @param bHideUnmounted
*
* @return The entire accessible novelties collection
*/
List<File> getGlobalNoveltiesPlaylist(boolean bHideUnmounted) {
List<File> alEligibleFiles = new ArrayList<File>(1000);
List<Track> tracks = TrackManager.getInstance().getTracks();
// Filter by age
CollectionUtils.filter(tracks,
new JajukPredicates.AgePredicate(Conf.getInt(Const.CONF_OPTIONS_NOVELTIES_AGE)));
// filter banned tracks
CollectionUtils.filter(tracks, new JajukPredicates.BannedTrackPredicate());
for (Track track : tracks) {
if (alEligibleFiles.size() > Const.NB_TRACKS_ON_ACTION) {
break;
}
File file = track.getBestFile(bHideUnmounted);
// try to get a mounted file
// (can return null)
if (file == null) {// none mounted file, take first file we find
continue;
}
alEligibleFiles.add(file);
}
// sort alphabetically and by date, newest first
Collections.sort(alEligibleFiles, new Comparator<File>() {
@Override
public int compare(File file1, File file2) {
String sCompared1 = file1.getTrack().getDiscoveryDate().getTime() + file1.getAbsolutePath();
String sCompared2 = file2.getTrack().getDiscoveryDate().getTime() + file2.getAbsolutePath();
return sCompared2.compareTo(sCompared1);
}
});
return alEligibleFiles;
}
/**
* Return a shuffled playlist with the entire accessible novelties collection.
*
* @return The entire accessible novelties collection
*/
public List<File> getShuffleNoveltiesPlaylist() {
List<File> alEligibleFiles = getGlobalNoveltiesPlaylist(true);
// song level, just shuffle full collection
if (Conf.getString(Const.CONF_NOVELTIES_MODE).equals(Const.MODE_TRACK)) {
Collections.shuffle(alEligibleFiles);
return alEligibleFiles;
}
// else return shuffle albums
else {
return getShuffledFilesByAlbum(alEligibleFiles);
}
}
/**
* Convenient method used to return shuffled files by album.
*
* @param alEligibleFiles
*
* @return Shuffled tracks by album
*/
private List<File> getShuffledFilesByAlbum(List<File> alEligibleFiles) {
// start with filling a set of albums containing
// at least one ready file
Map<Album, List<File>> albumsFiles = new HashMap<Album, List<File>>(alEligibleFiles.size() / 10);
for (File file : alEligibleFiles) {
// maintain a map between each albums and
// eligible files
Album album = file.getTrack().getAlbum();
List<File> files = albumsFiles.get(album);
if (files == null) {
files = new ArrayList<File>(10);
}
files.add(file);
albumsFiles.put(album, files);
}
// build output
List<File> out = new ArrayList<File>(alEligibleFiles.size());
List<Album> albums = new ArrayList<Album>(albumsFiles.keySet());
// we need to force a new shuffle as internal hashmap arrange items
Collections.shuffle(albums, UtilSystem.getRandom());
for (Album album : albums) {
List<File> files = albumsFiles.get(album);
Collections.shuffle(files, UtilSystem.getRandom());
out.addAll(files);
}
return out;
}
/**
* Gets the sorted by rate.
*
* @return a sorted set of the collection by rate, highest first
*/
private List<File> getSortedByRate() {
// use only mounted files
List<File> alEligibleFiles = getReadyFiles();
// now sort by rate
Collections.sort(alEligibleFiles, rateComparator);
return alEligibleFiles;
}
/**
* Return a shuffled playlist with the entire accessible bestof collection,
* best first.
*
* @return Shuffled best tracks (n% of favorite)
*/
public List<File> getGlobalBestofPlaylist() {
List<File> al = getSortedByRate();
// Filter banned files
CollectionUtils.filter(al, new JajukPredicates.BannedFilePredicate());
List<File> alBest = null;
if (al.size() > 0) {
// find superior interval value
int sup = (int) ((Const.BESTOF_PROPORTION) * al.size());
if (sup < 2) {
sup = al.size();
}
alBest = al.subList(0, sup - 1);
Collections.shuffle(alBest, UtilSystem.getRandom());
}
return alBest;
}
/**
* Return ordered (by rate) bestof files.
*
* @return top files
*/
public List<File> getBestOfFiles() {
// Don't refresh best of files at each call because it makes the playlist view
// unusable for bestof files : each time a file is played, the view is changed
if (alBestofFiles.size() == 0) {
refreshBestOfFiles();
}
return alBestofFiles;
}
/**
* Refresh best of files.
*/
public void refreshBestOfFiles() {
Log.debug("Invoking Refresh of BestOf-Files");
// clear data
alBestofFiles.clear();
// create a temporary table to remove unmounted files
int iNbBestofFiles = Integer.parseInt(Conf.getString(Const.CONF_BESTOF_TRACKS_SIZE));
List<File> alEligibleFiles = new ArrayList<File>(iNbBestofFiles);
List<Track> tracks = TrackManager.getInstance().getTracks();
// filter banned tracks
CollectionUtils.filter(tracks, new JajukPredicates.BannedTrackPredicate());
for (Track track : tracks) {
File file = track.getBestFile(Conf.getBoolean(Const.CONF_OPTIONS_HIDE_UNMOUNTED));
if (file != null) {
alEligibleFiles.add(file);
}
}
Collections.sort(alEligibleFiles, rateComparator);
// Keep as much items as we can
int i = 0;
while (i < alEligibleFiles.size() && i < iNbBestofFiles) {
File file = alEligibleFiles.get(i);
alBestofFiles.add(file);
i++;
}
}
/**
* Return next mounted file ( used in continue mode ).
*
* @param file :
* a file
*
* @return next file from entire collection
*/
public File getNextFile(File file) {
List<File> files = getFiles();
if (file == null) {
return null;
}
// look for a correct file from index to collection end
boolean bStarted = false;
for (File fileNext : files) {
if (bStarted) {
if (fileNext.isReady()) {
return fileNext;
}
} else {
if (fileNext.equals(file)) {
bStarted = true;
// Begin to consider files
// from this one
}
}
}
// Restart from collection from beginning
for (File fileNext : files) {
if (fileNext.isReady()) { // file must be on a mounted
// device not refreshing
return fileNext;
}
}
// none ready file
return null;
}
/**
* Return next mounted file from a different album than the provided file.
*
* @param file :
* a file
*
* @return next file from entire collection
*/
public File getNextAlbumFile(File file) {
File testedFile = file;
if (DirectoryManager.getInstance().getDirectories().size() > 1) {
while (testedFile.getDirectory().equals(file.getDirectory())) {
testedFile = getNextFile(testedFile);
}
}
if (!testedFile.getDirectory().equals(file.getDirectory())) {
return testedFile;
}
// Should not happen
else {
return null;
}
}
/**
* Return previous mounted file.
*
* @param file :
* a file
*
* @return previous file from entire collection
*/
public File getPreviousFile(File file) {
List<File> files = getFiles();
if (file == null) {
return null;
}
File filePrevious = null;
int i = files.indexOf(file);
// test if this file is the very first one
if (i == 0) {
Messages.showErrorMessage(128);
return null;
}
// look for a correct file from index to collection begin
boolean bOk = false;
for (int index = i - 1; index >= 0; index--) {
filePrevious = files.get(index);
if (filePrevious.isReady()) { // file must be on a mounted
// device not refreshing
bOk = true;
break;
}
}
if (bOk) {
return filePrevious;
}
return null;
}
/**
* Return whether the given file is the very first file from collection.
*
* @param file
*
* @return true, if checks if is very first file
*/
public boolean isVeryfirstFile(File file) {
List<File> files = getFiles();
if (file == null || files.size() == 0) {
return false;
}
return file.equals(files.get(0));
}
/*
* (non-Javadoc)
*
* @see org.jajuk.base.ItemManager#getIdentifier()
*/
@Override
public String getXMLTag() {
return Const.XML_FILES;
}
/**
* Gets the file by id.
*
* @param sID Item ID
*
* @return File matching the id
*/
public File getFileByID(String sID) {
return (File) getItemByID(sID);
}
/**
* Gets the files.
*
* @return ordered files list
*/
@SuppressWarnings("unchecked")
public List<File> getFiles() {
return (List<File>) getItems();
}
/**
* Gets the files iterator.
*
* @return files iterator
*/
@SuppressWarnings("unchecked")
public ReadOnlyIterator<File> getFilesIterator() {
return new ReadOnlyIterator<File>((Iterator<File>) getItemsIterator());
}
}