/* This file is part of Subsonic. Subsonic 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. Subsonic 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 Subsonic. If not, see <http://www.gnu.org/licenses/>. Copyright 2009 (C) Sindre Mehus */ package net.sourceforge.subsonic.service; import net.sf.ehcache.Ehcache; import net.sf.ehcache.Element; import net.sourceforge.subsonic.Logger; import net.sourceforge.subsonic.dao.MediaFileDao; import net.sourceforge.subsonic.domain.MediaFile; import net.sourceforge.subsonic.service.metadata.JaudiotaggerParser; import net.sourceforge.subsonic.service.metadata.MetaData; import net.sourceforge.subsonic.service.metadata.MetaDataParser; import net.sourceforge.subsonic.service.metadata.MetaDataParserFactory; import net.sourceforge.subsonic.util.FileUtil; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.filefilter.FileFileFilter; import org.apache.commons.lang.StringUtils; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Provides services for instantiating and caching media files and cover art. * * @author Sindre Mehus */ public class MediaFileService { private static final Logger LOG = Logger.getLogger(MediaFileService.class); private Ehcache mediaFileMemoryCache; private SecurityService securityService; private SettingsService settingsService; private SearchService searchService; private MediaFileDao mediaFileDao; private MetaDataParserFactory metaDataParserFactory; /** * Returns a media file instance for the given file. If possible, a cached value is returned. * * @param file A file on the local file system. * @return A media file instance. * @throws SecurityException If access is denied to the given file. */ public MediaFile getMediaFile(File file) { // Look in fast memory cache first. Element element = mediaFileMemoryCache.get(file); MediaFile cachedMediaFile = element == null ? null : (MediaFile) element.getObjectValue(); if (cachedMediaFile != null) { return cachedMediaFile; } if (!securityService.isReadAllowed(file)) { throw new SecurityException("Access denied to file " + file); } cachedMediaFile = mediaFileDao.getMediaFile(file.getPath()); if (cachedMediaFile != null) { if (useFastCache() || cachedMediaFile.getLastModified().getTime() >= FileUtil.lastModified(file)) { mediaFileMemoryCache.put(new Element(file, cachedMediaFile)); return cachedMediaFile; } } MediaFile mediaFile = createMediaFile(file); // Put in caches. mediaFileMemoryCache.put(new Element(file, mediaFile)); mediaFileDao.createOrUpdateMediaFile(mediaFile); return mediaFile; } /** * Returns a media file instance for the given path name. If possible, a cached value is returned. * * @param pathName A path name for a file on the local file system. * @return A memdia file instance. * @throws SecurityException If access is denied to the given file. */ public MediaFile getMediaFile(String pathName) { return getMediaFile(new File(pathName)); } public MediaFile getParentOf(MediaFile mediaFile) { if (mediaFile.getParentPath() == null) { return null; } return getMediaFile(mediaFile.getParentPath()); } public List<MediaFile> getChildrenOf(String parentPath, boolean includeFiles, boolean includeDirectories, boolean sort) { return getChildrenOf(new File(parentPath), includeFiles, includeDirectories, sort); } public List<MediaFile> getChildrenOf(File parent, boolean includeFiles, boolean includeDirectories, boolean sort) { return getChildrenOf(getMediaFile(parent), includeFiles, includeDirectories, sort); } /** * Returns all media files that are children of a given media file. * * @param includeFiles Whether files should be included in the result. * @param includeDirectories Whether directories should be included in the result. * @param sort Whether to sort files in the same directory. * @return All children media files. */ public List<MediaFile> getChildrenOf(MediaFile parent, boolean includeFiles, boolean includeDirectories, boolean sort) { if (!parent.isDirectory()) { return Collections.emptyList(); } // Make sure children are stored and up-to-date in the database. updateChildren(parent); List<MediaFile> result = new ArrayList<MediaFile>(); for (MediaFile child : mediaFileDao.getChildrenOf(parent.getPath())) { if (child.isDirectory() && includeDirectories) { result.add(child); } if (child.isFile() && includeFiles) { result.add(child); } } if (sort) { Collections.sort(result, new MediaFileSorter()); } return result; } /** * Returns the first direct child (excluding directories). * This is a convenience method. * * @return The first child, or <code>null</code> if not found. */ public MediaFile getFirstChildOf(MediaFile parent) { List<MediaFile> children = getChildrenOf(parent, true, false, true); return children.isEmpty() ? null : children.get(0); } /** * Returns all genres in the music collection. * * @return Sorted list of genres. */ public List<String> getGenres() { return mediaFileDao.getGenres(); } /** * Returns a number of random albums. * * @param count Maximum number of albums to return. * @return List of random albums. */ public List<MediaFile> getRandomAlbums(int count) { List<MediaFile> result = new ArrayList<MediaFile>(count); // Note: To avoid duplicates, we iterate over more than the requested number of items. for (int i = 0; i < count * 5; i++) { MediaFile album = mediaFileDao.getRandomAlbum(); if (album != null && securityService.isReadAllowed(album.getFile())) { if (!result.contains(album)) { result.add(album); // Enough items found? if (result.size() == count) { break; } } } } return result; } private void updateChildren(MediaFile parent) { // Check timestamps. if (parent.getChildrenLastUpdated().getTime() >= parent.getLastModified().getTime()) { return; } List<MediaFile> storedChildren = mediaFileDao.getChildrenOf(parent.getPath()); Map<String, MediaFile> storedChildrenMap = new HashMap<String, MediaFile>(); for (MediaFile child : storedChildren) { storedChildrenMap.put(child.getPath(), child); } List<File> children = listMediaFiles(parent.getFile()); for (File child : children) { if (storedChildrenMap.remove(child.getPath()) == null) { // Add children that are not already stored. mediaFileDao.createOrUpdateMediaFile(createMediaFile(child)); } } // Delete children that no longer exist on disk. for (String path : storedChildrenMap.keySet()) { mediaFileDao.deleteMediaFile(path); } // Update timestamp in parent. parent.setChildrenLastUpdated(parent.getLastModified()); mediaFileDao.createOrUpdateMediaFile(parent); } private List<File> listMediaFiles(File parent) { List<File> result = new ArrayList<File>(); for (File child : FileUtil.listFiles(parent)) { String suffix = FilenameUtils.getExtension(child.getName()).toLowerCase(); if (!isExcluded(child) && (FileUtil.isDirectory(child) || isMusicFile(suffix) || isVideoFile(suffix))) { result.add(child); } } return result; } private boolean isMusicFile(String suffix) { for (String s : settingsService.getMusicFileTypesAsArray()) { if (suffix.equals(s.toLowerCase())) { return true; } } return false; } private boolean isVideoFile(String suffix) { for (String s : settingsService.getVideoFileTypesAsArray()) { if (suffix.equals(s.toLowerCase())) { return true; } } return false; } /** * Returns whether the given file is excluded. * * @param file The child file in question. * @return Whether the child file is excluded. */ private boolean isExcluded(File file) { // Exclude all hidden files starting with a "." or "@eaDir" (thumbnail dir created on Synology devices). return file.getName().startsWith(".") || file.getName().startsWith("@eaDir"); } private MediaFile createMediaFile(File file) { // TODO: Set root and mediaType. MediaFile mediaFile = new MediaFile(); mediaFile.setPath(file.getPath()); mediaFile.setParentPath(file.getParent()); mediaFile.setFileSize(FileUtil.length(file)); mediaFile.setLastModified(new Date(FileUtil.lastModified(file))); mediaFile.setPlayCount(0); mediaFile.setChildrenLastUpdated(new Date(0)); mediaFile.setCreated(new Date()); mediaFile.setEnabled(true); mediaFile.setDirectory(FileUtil.isDirectory(file)); if (mediaFile.isFile()) { mediaFile.setFormat(StringUtils.trimToNull(StringUtils.lowerCase(FilenameUtils.getExtension(mediaFile.getPath())))); } else { // Is this an album? List<File> children = listMediaFiles(file); for (File child : children) { if (FileUtil.isFile(child)) { mediaFile.setAlbum(true); break; } } } if (mediaFile.isAlbum()) { try { File coverArt = findCoverArt(file); if (coverArt != null) { mediaFile.setCoverArtPath(coverArt.getPath()); } } catch (IOException x) { LOG.error("Failed to find cover art.", x); } } MetaDataParser parser = metaDataParserFactory.getParser(mediaFile); if (parser != null) { MetaData metaData = parser.getMetaData(mediaFile); mediaFile.setArtist(metaData.getArtist()); mediaFile.setAlbumName(metaData.getAlbumName()); mediaFile.setTitle(metaData.getTitle()); mediaFile.setDiscNumber(metaData.getDiscNumber()); mediaFile.setTrackNumber(metaData.getTrackNumber()); mediaFile.setGenre(metaData.getGenre()); mediaFile.setYear(metaData.getYear()); mediaFile.setDurationSeconds(metaData.getDurationSeconds()); mediaFile.setBitRate(metaData.getBitRate()); mediaFile.setVariableBitRate(metaData.getVariableBitRate()); mediaFile.setHeight(metaData.getHeight()); mediaFile.setWidth(metaData.getWidth()); } return mediaFile; } private boolean useFastCache() { return settingsService.isFastCacheEnabled() && !searchService.isIndexBeingCreated(); } /** * Returns a cover art image for the given media file. */ public File getCoverArt(MediaFile mediaFile) { if (mediaFile.getCoverArtFile() != null) { return mediaFile.getCoverArtFile(); } MediaFile parent = getParentOf(mediaFile); return parent == null ? null : parent.getCoverArtFile(); } /** * Finds a cover art image for the given directory, by looking for it on the disk. */ private File findCoverArt(File dir) throws IOException { File[] candidates = FileUtil.listFiles(dir, FileFileFilter.FILE); for (String mask : settingsService.getCoverArtFileTypesAsArray()) { for (File candidate : candidates) { if (candidate.getName().toUpperCase().endsWith(mask.toUpperCase()) && !candidate.getName().startsWith(".")) { return candidate; } } } // Look for embedded images in audiofiles. (Only check first audio file encountered). JaudiotaggerParser parser = new JaudiotaggerParser(); for (File candidate : candidates) { MediaFile mediaFile = getMediaFile(candidate); if (parser.isApplicable(mediaFile)) { if (parser.isImageAvailable(mediaFile)) { return candidate; } else { return null; } } } return null; } /** * Register in service locator so that non-Spring objects can access me. * This method is invoked automatically by Spring. */ public void init() { // TODO: Remove } public void setSecurityService(SecurityService securityService) { this.securityService = securityService; } public void setSettingsService(SettingsService settingsService) { this.settingsService = settingsService; } public void setMediaFileMemoryCache(Ehcache mediaFileMemoryCache) { this.mediaFileMemoryCache = mediaFileMemoryCache; } public void setSearchService(SearchService searchService) { this.searchService = searchService; } public void setMediaFileDao(MediaFileDao mediaFileDao) { this.mediaFileDao = mediaFileDao; } /** * Returns all media files that are children, grand-children etc of a given media file. * Directories are not included in the result. * * @param sort Whether to sort files in the same directory. * @return All descendant music files. */ public List<MediaFile> getDescendantsOf(MediaFile ancestor, boolean sort) { if (ancestor.isFile()) { return Arrays.asList(ancestor); } List<MediaFile> result = new ArrayList<MediaFile>(); for (MediaFile child : getChildrenOf(ancestor, true, true, sort)) { if (child.isDirectory()) { result.addAll(getDescendantsOf(child, sort)); } else { result.add(child); } } return result; } public void setMetaDataParserFactory(MetaDataParserFactory metaDataParserFactory) { this.metaDataParserFactory = metaDataParserFactory; } public void updateMediaFile(MediaFile mediaFile) { mediaFileDao.createOrUpdateMediaFile(mediaFile); } /** * Increments the play count and last played date for the given media file and its * directory. */ public void incrementPlayCount(MediaFile file) { file.setLastPlayed(new Date()); file.setPlayCount(file.getPlayCount() + 1); updateMediaFile(file); MediaFile parent = getParentOf(file); if (!parent.isRoot()) { parent.setLastPlayed(new Date()); parent.setPlayCount(parent.getPlayCount() + 1); updateMediaFile(parent); } } /** * Comparator for sorting media files. */ private static class MediaFileSorter implements Comparator<MediaFile> { public int compare(MediaFile a, MediaFile b) { if (a.isFile() && b.isDirectory()) { return 1; } if (a.isDirectory() && b.isFile()) { return -1; } if (a.isDirectory() && b.isDirectory()) { return a.getName().compareToIgnoreCase(b.getName()); } // Compare by disc number, if present. Integer discA = a.getDiscNumber(); Integer discB = b.getDiscNumber(); if (discA != null && discB != null) { int i = discA.compareTo(discB); if (i != 0) { return i; } } Integer trackA = a.getTrackNumber(); Integer trackB = b.getTrackNumber(); if (trackA == null && trackB != null) { return 1; } if (trackA != null && trackB == null) { return -1; } if (trackA == null && trackB == null) { return a.getName().compareToIgnoreCase(b.getName()); } return trackA.compareTo(trackB); } } }