/* * 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 com.google.common.io.Files; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; import javax.swing.ImageIcon; import javax.swing.JFileChooser; import javax.swing.JOptionPane; import javax.swing.SwingUtilities; import org.jajuk.services.bookmark.Bookmarks; import org.jajuk.services.players.QueueModel; import org.jajuk.services.players.StackItem; import org.jajuk.ui.widgets.JajukFileChooser; import org.jajuk.ui.windows.JajukMainWindow; import org.jajuk.util.Conf; import org.jajuk.util.Const; import org.jajuk.util.IconLoader; import org.jajuk.util.JajukFileFilter; import org.jajuk.util.JajukIcons; import org.jajuk.util.Messages; import org.jajuk.util.UtilFeatures; import org.jajuk.util.UtilString; import org.jajuk.util.UtilSystem; import org.jajuk.util.error.JajukException; import org.jajuk.util.filters.PlaylistFilter; import org.jajuk.util.log.Log; /** * A playlist * <p> * Physical item * * * TODO: refactoring items for this class: * - split up the code into separate implementations for the different types * - create a base abstract playlist class that is used in the various places and * have separate implementations for each of the types to separate the code better. */ public class Playlist extends PhysicalItem implements Comparable<Playlist> { /** * playlist type. */ public enum Type { NORMAL, QUEUE, NEW, BOOKMARK, BESTOF, NOVELTIES } /** Playlist parent directory. */ private Directory dParentDirectory; /** Files list, singleton. */ private List<File> alFiles; /** Associated physical file. */ private java.io.File fio; /** Playlist type. */ private final Type type; /** pre-calculated absolute path for perf. */ private String sAbs = null; /** Contains files outside device flag. */ private boolean bContainsExtFiles = false; /** Whether we ask for device mounting if required. */ private boolean askForMounting = true; /** * playlist constructor. * * @param type playlist type * @param sId * @param sName * @param dParentDirectory */ Playlist(final Type type, final String sId, final String sName, final Directory dParentDirectory) { super(sId, sName); this.dParentDirectory = dParentDirectory; setProperty(Const.XML_DIRECTORY, dParentDirectory == null ? "-1" : dParentDirectory.getID()); this.type = type; } /** * playlist constructor. * * @param sId * @param sName * @param dParentDirectory */ public Playlist(final String sId, final String sName, final Directory dParentDirectory) { this(Playlist.Type.NORMAL, sId, sName, dParentDirectory); } /** * Gets the type. * * @return Returns the Type. */ public Type getType() { return type; } /** * Add a file at the end of this playlist. * * @param file * * @throws JajukException the jajuk exception */ public void addFile(final File file) throws JajukException { final List<File> al = getFiles(); final int index = al.size(); addFile(index, file); } /** * Add a file to this playlist. at a given index. * * @param index * @param file * * @throws JajukException the jajuk exception */ public void addFile(final int index, final File file) throws JajukException { if (type == Playlist.Type.BOOKMARK) { Bookmarks.getInstance().addFile(index, file); } else if (type == Type.QUEUE) { final StackItem item = new StackItem(file); item.setUserLaunch(false); // set repeat mode : if previous item is repeated, repeat as // well if (index > 0) { final StackItem itemPrevious = QueueModel.getItem(index - 1); if ((itemPrevious != null) && itemPrevious.isRepeat()) { item.setRepeat(true); } else { item.setRepeat(false); } // insert this track in the fifo QueueModel.insert(item, index); } else { // start immediately playing QueueModel.push(item, false); } // we don't need to adjust the alFiles here because for playlist type QUEUE // the contents is taken directly from the QueueModel in case of } else { getFiles().add(index, file); } } /** * Add some files to this playlist. * * @param alFilesToAdd : List of Files * @param position */ public void addFiles(final List<File> alFilesToAdd, int position) { try { int offset = 0; for (File file : alFilesToAdd) { addFile(position + offset, file); offset++; } } catch (final Exception e) { Log.error(e); } } /** * Clear playlist. */ public void clear() { if (type == Type.BOOKMARK) { // bookmark // playlist Bookmarks.getInstance().clear(); } else if (getType() == Type.QUEUE) { QueueModel.clear(); } else { if (alFiles == null) { return; } alFiles.clear(); } } /** * Update playlist on disk if needed. * * @throws JajukException the jajuk exception */ public void commit() throws JajukException { java.io.File temp = null; try { /* * Due to bug #1046, we use a temporary file In some special cases (reproduced under Linux, * JRE SUN 1.6.0_04, CIFS mount, 777 rights file), probably due to a JRE bug, files cannot be * opened (FileNotFound? Exception, permission denied) and the file is voided (0 bytes) and is * closed (checked with lsof). */ temp = new java.io.File(getAbsolutePath() + '~'); BufferedWriter bw = new BufferedWriter(new FileWriter(temp)); try { bw.write(Const.PLAYLIST_NOTE); bw.newLine(); final Iterator<File> it = getFiles().iterator(); while (it.hasNext()) { final File file = it.next(); if (file.getFIO().getParent().equals(getFIO().getParent())) { bw.write(file.getName()); } else { bw.write(file.getAbsolutePath()); } bw.newLine(); } bw.flush(); } finally { bw.close(); } } catch (final IOException e) { throw new JajukException(28, getName(), e); } // Now move the temp file to final one if everything seems ok moveTempPlaylistFile(temp); } /** * Move temp playlist file. * * @param temp * * @throws JajukException the jajuk exception */ private void moveTempPlaylistFile(java.io.File temp) throws JajukException { if (temp.exists() && temp.length() > 0) { try { UtilSystem.copy(temp, getFIO()); UtilSystem.deleteFile(temp); } catch (final Exception e1) { throw new JajukException(28, getName(), e1); } } else { try { // Try to remove the temp file UtilSystem.deleteFile(temp); } catch (final Exception e1) { Log.error(e1); } throw new JajukException(28, getName()); } } /** * Alphabetical comparator used to display ordered lists of playlists * <p> * Sort ignoring cases * </p>. * * @param o * * @return comparison result */ @Override public int compareTo(final Playlist o) { // not equal if other is null if (o == null) { return -1; } // Perf: leave if items are equals if (o.equals(this)) { return 0; } final Playlist otherPlaylistFile = o; final String abs = getName() + (getDirectory() != null ? getAbsolutePath() : ""); final String sOtherAbs = otherPlaylistFile.getName() + (otherPlaylistFile.getDirectory() != null ? otherPlaylistFile.getAbsolutePath() : ""); // We must be consistent with equals, see // http://java.sun.com/javase/6/docs/api/java/lang/Comparable.html int comp = abs.compareToIgnoreCase(sOtherAbs); if (comp == 0) { return abs.compareTo(sOtherAbs); } else { return comp; } } /** * Contains ext files. * * @return whether this playlist contains files located out of known devices */ public boolean containsExtFiles() { return bContainsExtFiles; } /** * Down a track in the playlist. * * @param index index of the track to down */ public void down(final int index) { if (type == Type.BOOKMARK) { Bookmarks.getInstance().down(index); } else if (type == Type.QUEUE) { QueueModel.down(index); } else if ((alFiles != null) && (index < alFiles.size() - 1)) { // the last track cannot go deeper // n+1 file becomes nth file Collections.swap(alFiles, index, index + 1); } } /** * Equal method to check two playlists are identical. * * @param otherPlaylistFile * * @return true, if equals */ @Override public boolean equals(final Object otherPlaylistFile) { // also catches null by definition if (!(otherPlaylistFile instanceof Playlist)) { return false; } final Playlist plfOther = (Playlist) otherPlaylistFile; return getID().equals(plfOther.getID()) && plfOther.getType() == type; } /** * Return absolute file path name. * * @return String */ public String getAbsolutePath() { if (type == Type.NORMAL) { if (sAbs != null) { return sAbs; } final Directory dCurrent = getDirectory(); final StringBuilder sbOut = new StringBuilder(dCurrent.getDevice().getUrl()) .append(dCurrent.getRelativePath()).append(java.io.File.separatorChar).append(getName()); sAbs = sbOut.toString(); } else { // smart playlist path depends on the user selected from the save as // file chooser and has been set using the setFio() method just before // that don't use "getFIO()" here, as otherwise we can cause an endless // loop as getFIO() calls this method as well if (fio == null) { return ""; } sAbs = fio.getAbsolutePath(); } return sAbs; } /* (non-Javadoc) * @see org.jajuk.base.Item#getTitle() */ @Override public String getTitle() { return Messages.getString("Item_Playlist_File") + " : " + getName(); } /** * Gets the directory. * * @return the directory */ public Directory getDirectory() { return dParentDirectory; } /** * Gets the files. * * @return Returns the list of files this playlist maps to * * @throws JajukException if the playlist cannot be mounted or cannot be read */ public List<File> getFiles() throws JajukException { // if normal playlist, propose to mount device if unmounted if ((getType() == Type.NORMAL) && !isReady()) { // We already asked but user didn't want to mount the device -> leave if (!askForMounting) { throw new JajukException(141, getFIO().getAbsolutePath()); } // No more ask for mounting askForMounting = false; final String sMessage = Messages.getString("Error.025") + " (" + getDirectory().getDevice().getName() + Messages.getString("FIFO.4"); final int i = Messages.getChoice(sMessage, JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.INFORMATION_MESSAGE); if (i == JOptionPane.YES_OPTION) { try { // mount the device is required getDirectory().getDevice().mount(true); } catch (final Exception e) { throw new JajukException(141, getFIO().getAbsolutePath(), e); } } else { throw new JajukException(141, getFIO().getAbsolutePath()); } } if ((type == Type.NORMAL) && (alFiles == null)) { // normal playlist, test if list is null for performances (avoid // reading the m3u file twice) if (getFIO().exists() && getFIO().canRead()) { // check device is mounted load(); // populate playlist } else { // error accessing playlist throw new JajukException(9, getFIO().getAbsolutePath()); } } else if (type.equals(Type.BESTOF)) { alFiles = FileManager.getInstance().getBestOfFiles(); } else if (type.equals(Type.NOVELTIES)) { alFiles = FileManager.getInstance().getGlobalNoveltiesPlaylist( Conf.getBoolean(Const.CONF_OPTIONS_HIDE_UNMOUNTED)); } else if (type.equals(Type.BOOKMARK)) { alFiles = Bookmarks.getInstance().getFiles(); } else if (type.equals(Type.QUEUE)) { List<StackItem> items = QueueModel.getQueue(); List<File> files = new ArrayList<File>(items.size()); for (StackItem si : items) { files.add(si.getFile()); } alFiles = files; } else if (type.equals(Type.NEW) && alFiles == null) { alFiles = new ArrayList<File>(10); } return alFiles; } /** * Gets the fio. * * @return Returns the fio. */ public java.io.File getFIO() { if (fio == null) { fio = new java.io.File(getAbsolutePath()); } return fio; } /* * (non-Javadoc) * * @see org.jajuk.base.Item#getHumanValue(java.lang.String) */ @Override public String getHumanValue(final String sKey) { if (Const.XML_DIRECTORY.equals(sKey)) { final Directory dParent = DirectoryManager.getInstance().getDirectoryByID( getStringValue(sKey)); return dParent.getFio().getAbsolutePath(); } else {// default return super.getHumanValue(sKey); } } /* * (non-Javadoc) * * @see org.jajuk.base.Item#getIconRepresentation() */ @Override public ImageIcon getIconRepresentation() { return IconLoader.getIcon(JajukIcons.PLAYLIST_FILE); } /* * (non-Javadoc) * * @see org.jajuk.base.Item#getIdentifier() */ @Override public final String getXMLTag() { return Const.XML_PLAYLIST_FILE; } /** * Return true the file can be accessed right now. * * @return true the file can be accessed right now */ public boolean isReady() { if (getDirectory() != null && getDirectory().getDevice() != null && getDirectory().getDevice().isMounted()) { return true; } return false; } /** * Parse a playlist. * * @throws JajukException the jajuk exception */ public void load() throws JajukException { final List<File> files = new ArrayList<File>(10); try { BufferedReader br = new BufferedReader(new FileReader(getFIO())); try { String sLine = null; boolean bUnknownDevicesMessage = false; while ((sLine = br.readLine()) != null) { if (sLine.length() == 0) { // void line continue; } // replace '\' by '/' sLine = sLine.replace('\\', '/'); // deal with url beginning by "./something" if (sLine.startsWith("./")) { sLine = sLine.substring(1, sLine.length()); } // comment if (sLine.charAt(0) == '#') { continue; } else { java.io.File fio = null; final StringBuilder sbFileDir = new StringBuilder(getDirectory().getAbsolutePath()); // Add a trailing / at the end of the url if required if (sLine.charAt(0) != '/') { sbFileDir.append("/"); } // take a look relatively to playlist directory to check if the file exists fio = new java.io.File(sbFileDir.append(sLine).toString()); String fioAbsPath = fio.getAbsolutePath(); // Check for file existence in jajuk collection using Guava Files.simplyPath // Don't use File.getAbsolutePath() because its result can contain ./ or ../ // Don't use File.getCanonicalPath() because it resolves symlinks under unix. File jajukFile = FileManager.getInstance() .getFileByPath(Files.simplifyPath(fioAbsPath)); if (jajukFile == null) { // check if this file is known in collection fio = new java.io.File(sLine); // check if given url is not absolute jajukFile = FileManager.getInstance().getFileByPath(fio.getAbsolutePath()); if (jajukFile == null) { // no more ? leave bUnknownDevicesMessage = true; continue; } } files.add(jajukFile); } } // display a warning message if the playlist contains unknown // items if (bUnknownDevicesMessage) { bContainsExtFiles = true; } } finally { br.close(); } } catch (final IOException e) { Log.error(17, "{{" + getName() + "}}", e); throw new JajukException(17, (getDirectory() != null && getFIO() != null ? getFIO() .getAbsolutePath() : "<unknown>"), e); } this.alFiles = files; } /** * Play a playlist. * * @throws JajukException the jajuk exception */ public void play() throws JajukException { alFiles = getFiles(); if ((alFiles == null) || (alFiles.size() == 0)) { Messages.showErrorMessage(18); } else { QueueModel.push( UtilFeatures.createStackItems(UtilFeatures.applyPlayOption(alFiles), Conf.getBoolean(Const.CONF_STATE_REPEAT_ALL), true), false); } } /** * Remove an set of tracks index from the playlist. * We expect at this point that the playlist has already been loaded once at least. * * @param Set<Integer> indexes * Array of index to drop. We expect the array to contain integers sorted by ascendent order. * **/ public void remove(final Set<Integer> indexes) { if (type == Type.BOOKMARK) { for (int index : indexes) { Bookmarks.getInstance().remove(index); } } else if (type == Type.QUEUE) { QueueModel.remove(indexes); } else { for (int index : indexes) { alFiles.remove(index); } } } /** * Remove a track at specified index from the playlist. * * @param int index * index of the track to remove * **/ public void remove(final int index) { Set<Integer> indexes = new HashSet<Integer>(1); indexes.add(index); remove(indexes); } /** * Replace a file inside a playlist. * * @param fOld * @param fNew * * @throws JajukException the jajuk exception */ void replaceFile(final File fOld, final File fNew) throws JajukException { if (type == Type.BOOKMARK) { List<File> files = Bookmarks.getInstance().getFiles(); final Iterator<File> it = files.iterator(); for (int i = 0; it.hasNext(); i++) { final File fileToTest = it.next(); if (fileToTest.equals(fOld)) { files.set(i, fNew); /* * this leads to ConcurrentModificationException: Bookmarks.getInstance().remove(i); * Bookmarks.getInstance().addFile(i, fNew); */ } } } else if (type == Type.QUEUE) { final Iterator<StackItem> it = QueueModel.getQueue().iterator(); for (int i = 0; it.hasNext(); i++) { final File fileToTest = it.next().getFile(); if (fileToTest.equals(fOld)) { QueueModel.remove(i); // just remove final List<StackItem> al = new ArrayList<StackItem>(1); al.add(new StackItem(fNew)); QueueModel.insert(al, i); } } } else { final Iterator<File> it = alFiles.iterator(); for (int i = 0; it.hasNext(); i++) { final File fileToTest = it.next(); if (fileToTest.equals(fOld)) { alFiles.set(i, fNew); try { commit();// save changed playlist } catch (final JajukException e) { Log.error(e); } } } } } /** * Reset pre-calculated paths*. */ protected void reset() { sAbs = null; fio = null; } /** * Save as... the playlist * * @throws JajukException the jajuk exception * @throws InterruptedException the interrupted exception * @throws InvocationTargetException the invocation target exception */ public void saveAs() throws JajukException, InterruptedException, InvocationTargetException { FileChooserRunnable runnable = new FileChooserRunnable(); // We have to wait the runnable to ensure that the caller get the correct selected // path before remaining code execution SwingUtilities.invokeAndWait(runnable); if (runnable.getException() != null) { throw runnable.getException(); } } /** * Sets the files. * * @param alFiles The alFiles to set. */ public void setFiles(final List<File> alFiles) { this.alFiles = alFiles; } /** * Sets the fio. * * @param fio The fio to set. */ public void setFIO(final java.io.File fio) { this.fio = fio; } /** * Return whether this item should be hidden with hide option. * * @return whether this item should be hidden with hide option */ public boolean shouldBeHidden() { if (getDirectory().getDevice().isMounted() || (!Conf.getBoolean(Const.CONF_OPTIONS_HIDE_UNMOUNTED))) { // option "only display mounted devices" return false; } return true; } /** * toString method. * * @return the string */ @Override public String toString() { if (dParentDirectory == null) { return "playlist[ID=" + getID() + " Name={{" + getName() + "}} " + " Dir=<null>]"; } else { return "playlist[ID=" + getID() + " Name={{" + getName() + "}} " + " Dir=" + dParentDirectory.getID() + "]"; } } /** * Up a track in the playlist. * * @param index */ public void up(final int index) { if (type == Type.BOOKMARK) { Bookmarks.getInstance().up(index); } else if (type == Playlist.Type.QUEUE) { QueueModel.up(index); } else if ((alFiles != null) && (index > 0)) { // the first track // cannot go further // n-1 file becomes nth file Collections.swap(alFiles, index, index - 1); } } /** * Gets the playlist average rating. * * @return playlist average rating */ @Override public long getRate() { if (alFiles == null) { return 0; } float rate = 0f; int nb = 0; for (File file : alFiles) { rate += file.getTrack().getRate(); nb++; } return Math.round(rate / nb); } /** * Gets the hits. * * @return total nb of hits */ public long getHits() { if (alFiles == null) { return 0; } int hits = 0; for (File file : alFiles) { hits += file.getTrack().getHits(); } return hits; } /** * Return full playlist length in secs. * * @return the duration */ public long getDuration() { if (alFiles == null) { return 0; } long length = 0; for (File file : alFiles) { length += file.getTrack().getDuration(); } return length; } /** * Gets the nb of tracks. * * @return playlist nb of tracks */ public int getNbOfTracks() { if (alFiles == null) { return 0; } return alFiles.size(); } /** * Gets the any. * * @return a human representation of all concatenated properties */ @Override public String getAny() { // rebuild any StringBuilder sb = new StringBuilder(100); sb.append(super.getAny()); // add all files-based properties // now add others properties sb.append(getDirectory().getDevice().getName()); sb.append(getAbsolutePath()); return sb.toString(); } /** * Return true is the specified directory is an ancestor for this playlist. * * @param directory * * @return true, if checks for ancestor */ public boolean hasAncestor(Directory directory) { Directory dirTested = getDirectory(); while (true) { if (dirTested.equals(directory)) { return true; } else { dirTested = dirTested.getParentDirectory(); if (dirTested == null) { return false; } } } } /** * Small helper class to be able to run * the FileChoose inside the EDT thread of Swing. */ private final class FileChooserRunnable implements Runnable { // records if there are exceptions during doing the call JajukException ex = null; /* * (non-Javadoc) * * @see java.lang.Runnable#run() */ @Override public void run() { try { final JajukFileChooser jfchooser = new JajukFileChooser(new JajukFileFilter( PlaylistFilter.getInstance())); jfchooser.setDialogType(JFileChooser.SAVE_DIALOG); jfchooser.setAcceptDirectories(true); String sPlaylist = Const.DEFAULT_PLAYLIST_FILE; // computes new playlist alFiles = getFiles(); if (alFiles.size() > 0) { final File file = alFiles.get(0); if (getType() == Type.BESTOF) { sPlaylist = file.getDevice().getUrl() + java.io.File.separatorChar + Const.FILE_DEFAULT_BESTOF_PLAYLIST; } else if (getType() == Type.BOOKMARK) { sPlaylist = file.getDevice().getUrl() + java.io.File.separatorChar + Const.FILE_DEFAULT_BOOKMARKS_PLAYLIST; } else if (getType() == Type.NOVELTIES) { sPlaylist = file.getDevice().getUrl() + java.io.File.separatorChar + Const.FILE_DEFAULT_NOVELTIES_PLAYLIST; } else if (getType() == Type.QUEUE) { sPlaylist = file.getDevice().getUrl() + java.io.File.separatorChar + Const.FILE_DEFAULT_QUEUE_PLAYLIST + UtilString.getAdditionDateFormatter().format(new Date()); } else { sPlaylist = file.getDirectory().getAbsolutePath() + java.io.File.separatorChar + UtilSystem.getNormalizedFilename(file.getTrack().getHumanValue(Const.XML_ALBUM)); } } else { return; } jfchooser.setSelectedFile(new java.io.File(sPlaylist + "." + Const.EXT_PLAYLIST)); final int returnVal = jfchooser.showSaveDialog(JajukMainWindow.getInstance()); if (returnVal == JFileChooser.APPROVE_OPTION) { java.io.File file = jfchooser.getSelectedFile(); // add automatically the extension if required if (file.getAbsolutePath().endsWith(Const.EXT_PLAYLIST)) { file = new java.io.File(file.getAbsolutePath()); } else { file = new java.io.File(file.getAbsolutePath() + "." + Const.EXT_PLAYLIST); } // set new file path ( this playlist is a special playlist, just in // memory ) setFIO(file); commit(); // write it on the disk } } catch (JajukException e) { ex = e; } } /** * Returns any exception caught during running the file chooser. * * @return null if no exception was caught, the actual exception otherwise. */ public JajukException getException() { return ex; } } }