/* This file is part of Airsonic. Airsonic 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. Airsonic 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 Airsonic. If not, see . Copyright 2016 (C) Airsonic Authors Based upon Subsonic, Copyright 2009 (C) Sindre Mehus */ package org.airsonic.player.service; import net.sf.ehcache.Ehcache; import net.sf.ehcache.Element; import org.airsonic.player.dao.AlbumDao; import org.airsonic.player.dao.MediaFileDao; import org.airsonic.player.domain.*; import org.airsonic.player.service.metadata.JaudiotaggerParser; import org.airsonic.player.service.metadata.MetaData; import org.airsonic.player.service.metadata.MetaDataParser; import org.airsonic.player.service.metadata.MetaDataParserFactory; import org.airsonic.player.util.FileUtil; import org.apache.commons.io.FilenameUtils; import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.util.*; /** * Provides services for instantiating and caching media files and cover art. * * @author Sindre Mehus */ @Service public class MediaFileService { private static final Logger LOG = LoggerFactory.getLogger(MediaFileService.class); @Autowired private Ehcache mediaFileMemoryCache; @Autowired private SecurityService securityService; @Autowired private SettingsService settingsService; @Autowired private MediaFileDao mediaFileDao; @Autowired private AlbumDao albumDao; @Autowired private JaudiotaggerParser parser; @Autowired private MetaDataParserFactory metaDataParserFactory; private boolean memoryCacheEnabled = true; /** * 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, or null if not found. * @throws SecurityException If access is denied to the given file. */ public MediaFile getMediaFile(File file) { return getMediaFile(file, settingsService.isFastCacheEnabled()); } /** * 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, or null if not found. * @throws SecurityException If access is denied to the given file. */ public MediaFile getMediaFile(File file, boolean useFastCache) { // Look in fast memory cache first. MediaFile result = getFromMemoryCache(file); if (result != null) { return result; } if (!securityService.isReadAllowed(file)) { throw new SecurityException("Access denied to file " + file); } // Secondly, look in database. result = mediaFileDao.getMediaFile(file.getPath()); if (result != null) { result = checkLastModified(result, useFastCache); putInMemoryCache(file, result); return result; } if (!FileUtil.exists(file)) { return null; } // Not found in database, must read from disk. result = createMediaFile(file); // Put in cache and database. putInMemoryCache(file, result); mediaFileDao.createOrUpdateMediaFile(result); return result; } /** * 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 media file instance. * @throws SecurityException If access is denied to the given file. */ public MediaFile getMediaFile(String pathName) { return getMediaFile(new File(pathName)); } // TODO: Optimize with memory caching. public MediaFile getMediaFile(int id) { MediaFile mediaFile = mediaFileDao.getMediaFile(id); if (mediaFile == null) { return null; } if (!securityService.isReadAllowed(mediaFile.getFile())) { throw new SecurityException("Access denied to file " + mediaFile); } return checkLastModified(mediaFile, settingsService.isFastCacheEnabled()); } public MediaFile getParentOf(MediaFile mediaFile) { if (mediaFile.getParentPath() == null) { return null; } return getMediaFile(mediaFile.getParentPath()); } private MediaFile checkLastModified(MediaFile mediaFile, boolean useFastCache) { if (useFastCache || (mediaFile.getVersion() >= MediaFileDao.VERSION && mediaFile.getChanged().getTime() >= FileUtil.lastModified(mediaFile.getFile()))) { return mediaFile; } mediaFile = createMediaFile(mediaFile.getFile()); mediaFileDao.createOrUpdateMediaFile(mediaFile); return mediaFile; } /** * 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 getChildrenOf(MediaFile parent, boolean includeFiles, boolean includeDirectories, boolean sort) { return getChildrenOf(parent, includeFiles, includeDirectories, sort, settingsService.isFastCacheEnabled()); } /** * 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 getChildrenOf(MediaFile parent, boolean includeFiles, boolean includeDirectories, boolean sort, boolean useFastCache) { if (!parent.isDirectory()) { return Collections.emptyList(); } // Make sure children are stored and up-to-date in the database. if (!useFastCache) { updateChildren(parent); } List result = new ArrayList(); for (MediaFile child : mediaFileDao.getChildrenOf(parent.getPath())) { child = checkLastModified(child, useFastCache); if (child.isDirectory() && includeDirectories) { result.add(child); } if (child.isFile() && includeFiles) { result.add(child); } } if (sort) { Comparator comparator = new MediaFileComparator(settingsService.isSortAlbumsByYear()); // Note: Intentionally not using Collections.sort() since it can be problematic on Java 7. // http://www.oracle.com/technetwork/java/javase/compatibility-417013.html#jdk7 Set set = new TreeSet(comparator); set.addAll(result); result = new ArrayList(set); } return result; } /** * Returns whether the given file is the root of a media folder. * * @see MusicFolder */ public boolean isRoot(MediaFile mediaFile) { for (MusicFolder musicFolder : settingsService.getAllMusicFolders(false, true)) { if (mediaFile.getPath().equals(musicFolder.getPath().getPath())) { return true; } } return false; } /** * Returns all genres in the music collection. * * @param sortByAlbum Whether to sort by album count, rather than song count. * @return Sorted list of genres. */ public List getGenres(boolean sortByAlbum) { return mediaFileDao.getGenres(sortByAlbum); } /** * Returns the most frequently played albums. * * @param offset Number of albums to skip. * @param count Maximum number of albums to return. * @param musicFolders Only return albums in these folders. * @return The most frequently played albums. */ public List getMostFrequentlyPlayedAlbums(int offset, int count, List musicFolders) { return mediaFileDao.getMostFrequentlyPlayedAlbums(offset, count, musicFolders); } /** * Returns the most recently played albums. * * @param offset Number of albums to skip. * @param count Maximum number of albums to return. * @param musicFolders Only return albums in these folders. * @return The most recently played albums. */ public List getMostRecentlyPlayedAlbums(int offset, int count, List musicFolders) { return mediaFileDao.getMostRecentlyPlayedAlbums(offset, count, musicFolders); } /** * Returns the most recently added albums. * * @param offset Number of albums to skip. * @param count Maximum number of albums to return. * @param musicFolders Only return albums in these folders. * @return The most recently added albums. */ public List getNewestAlbums(int offset, int count, List musicFolders) { return mediaFileDao.getNewestAlbums(offset, count, musicFolders); } /** * Returns the most recently starred albums. * * @param offset Number of albums to skip. * @param count Maximum number of albums to return. * @param username Returns albums starred by this user. * @param musicFolders Only return albums from these folders. * @return The most recently starred albums for this user. */ public List getStarredAlbums(int offset, int count, String username, List musicFolders) { return mediaFileDao.getStarredAlbums(offset, count, username, musicFolders); } /** * Returns albums in alphabetical order. * * @param offset Number of albums to skip. * @param count Maximum number of albums to return. * @param byArtist Whether to sort by artist name * @param musicFolders Only return albums in these folders. * @return Albums in alphabetical order. */ public List getAlphabeticalAlbums(int offset, int count, boolean byArtist, List musicFolders) { return mediaFileDao.getAlphabeticalAlbums(offset, count, byArtist, musicFolders); } /** * Returns albums within a year range. * * @param offset Number of albums to skip. * @param count Maximum number of albums to return. * @param fromYear The first year in the range. * @param toYear The last year in the range. * @param musicFolders Only return albums in these folders. * @return Albums in the year range. */ public List getAlbumsByYear(int offset, int count, int fromYear, int toYear, List musicFolders) { return mediaFileDao.getAlbumsByYear(offset, count, fromYear, toYear, musicFolders); } /** * Returns albums in a genre. * * @param offset Number of albums to skip. * @param count Maximum number of albums to return. * @param genre The genre name. * @param musicFolders Only return albums in these folders. * @return Albums in the genre. */ public List getAlbumsByGenre(int offset, int count, String genre, List musicFolders) { return mediaFileDao.getAlbumsByGenre(offset, count, genre, musicFolders); } /** * Returns random songs for the given parent. * * @param parent The parent. * @param count Max number of songs to return. * @return Random songs. */ public List getRandomSongsForParent(MediaFile parent, int count) { List children = getDescendantsOf(parent, false); removeVideoFiles(children); if (children.isEmpty()) { return children; } Collections.shuffle(children); return children.subList(0, Math.min(count, children.size())); } /** * Returns random songs matching search criteria. * */ public List getRandomSongs(RandomSearchCriteria criteria, String username) { return mediaFileDao.getRandomSongs(criteria, username); } /** * Removes video files from the given list. */ public void removeVideoFiles(List files) { Iterator iterator = files.iterator(); while (iterator.hasNext()) { MediaFile file = iterator.next(); if (file.isVideo()) { iterator.remove(); } } } public Date getMediaFileStarredDate(int id, String username) { return mediaFileDao.getMediaFileStarredDate(id, username); } public void populateStarredDate(List mediaFiles, String username) { for (MediaFile mediaFile : mediaFiles) { populateStarredDate(mediaFile, username); } } public void populateStarredDate(MediaFile mediaFile, String username) { Date starredDate = mediaFileDao.getMediaFileStarredDate(mediaFile.getId(), username); mediaFile.setStarredDate(starredDate); } private void updateChildren(MediaFile parent) { // Check timestamps. if (parent.getChildrenLastUpdated().getTime() >= parent.getChanged().getTime()) { return; } List storedChildren = mediaFileDao.getChildrenOf(parent.getPath()); Map storedChildrenMap = new HashMap(); for (MediaFile child : storedChildren) { storedChildrenMap.put(child.getPath(), child); } List children = filterMediaFiles(FileUtil.listFiles(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.getChanged()); parent.setPresent(true); mediaFileDao.createOrUpdateMediaFile(parent); } public List filterMediaFiles(File[] candidates) { List result = new ArrayList(); for (File candidate : candidates) { String suffix = FilenameUtils.getExtension(candidate.getName()).toLowerCase(); if (!isExcluded(candidate) && (FileUtil.isDirectory(candidate) || isAudioFile(suffix) || isVideoFile(suffix))) { result.add(candidate); } } return result; } private boolean isAudioFile(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) { if (settingsService.getIgnoreSymLinks() && Files.isSymbolicLink(file.toPath())) { LOG.info("excluding symbolic link " + file.toPath()); return true; } String name = file.getName(); if (settingsService.getExcludePattern() != null && settingsService.getExcludePattern().matcher(name).find()) { LOG.info("excluding file which matches exclude pattern " + settingsService.getExcludePatternString() + ": " + file.toPath()); return true; } // Exclude all hidden files starting with a single "." or "@eaDir" (thumbnail dir created on Synology devices). return (name.startsWith(".") && !name.startsWith("..")) || name.startsWith("@eaDir") || "Thumbs.db".equals(name); } private MediaFile createMediaFile(File file) { MediaFile existingFile = mediaFileDao.getMediaFile(file.getPath()); MediaFile mediaFile = new MediaFile(); Date lastModified = new Date(FileUtil.lastModified(file)); mediaFile.setPath(file.getPath()); mediaFile.setFolder(securityService.getRootFolderForFile(file)); mediaFile.setParentPath(file.getParent()); mediaFile.setChanged(lastModified); mediaFile.setLastScanned(new Date()); mediaFile.setPlayCount(existingFile == null ? 0 : existingFile.getPlayCount()); mediaFile.setLastPlayed(existingFile == null ? null : existingFile.getLastPlayed()); mediaFile.setComment(existingFile == null ? null : existingFile.getComment()); mediaFile.setChildrenLastUpdated(new Date(0)); mediaFile.setCreated(lastModified); mediaFile.setMediaType(MediaFile.MediaType.DIRECTORY); mediaFile.setPresent(true); if (file.isFile()) { MetaDataParser parser = metaDataParserFactory.getParser(file); if (parser != null) { MetaData metaData = parser.getMetaData(file); mediaFile.setArtist(metaData.getArtist()); mediaFile.setAlbumArtist(metaData.getAlbumArtist()); 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()); mediaFile.setMusicBrainzReleaseId(metaData.getMusicBrainzReleaseId()); } String format = StringUtils.trimToNull(StringUtils.lowerCase(FilenameUtils.getExtension(mediaFile.getPath()))); mediaFile.setFormat(format); mediaFile.setFileSize(FileUtil.length(file)); mediaFile.setMediaType(getMediaType(mediaFile)); } else { // Is this an album? if (!isRoot(mediaFile)) { File[] children = FileUtil.listFiles(file); File firstChild = null; for (File child : filterMediaFiles(children)) { if (FileUtil.isFile(child)) { firstChild = child; break; } } if (firstChild != null) { mediaFile.setMediaType(MediaFile.MediaType.ALBUM); // Guess artist/album name, year and genre. MetaDataParser parser = metaDataParserFactory.getParser(firstChild); if (parser != null) { MetaData metaData = parser.getMetaData(firstChild); mediaFile.setArtist(metaData.getAlbumArtist()); mediaFile.setAlbumName(metaData.getAlbumName()); mediaFile.setYear(metaData.getYear()); mediaFile.setGenre(metaData.getGenre()); } // Look for cover art. try { File coverArt = findCoverArt(children); if (coverArt != null) { mediaFile.setCoverArtPath(coverArt.getPath()); } } catch (IOException x) { LOG.error("Failed to find cover art.", x); } } else { mediaFile.setArtist(file.getName()); } } } return mediaFile; } private MediaFile.MediaType getMediaType(MediaFile mediaFile) { if (isVideoFile(mediaFile.getFormat())) { return MediaFile.MediaType.VIDEO; } String path = mediaFile.getPath().toLowerCase(); String genre = StringUtils.trimToEmpty(mediaFile.getGenre()).toLowerCase(); if (path.contains("podcast") || genre.contains("podcast")) { return MediaFile.MediaType.PODCAST; } if (path.contains("audiobook") || genre.contains("audiobook") || path.contains("audio book") || genre.contains("audio book")) { return MediaFile.MediaType.AUDIOBOOK; } return MediaFile.MediaType.MUSIC; } public void refreshMediaFile(MediaFile mediaFile) { mediaFile = createMediaFile(mediaFile.getFile()); mediaFileDao.createOrUpdateMediaFile(mediaFile); mediaFileMemoryCache.remove(mediaFile.getFile()); } private void putInMemoryCache(File file, MediaFile mediaFile) { if (memoryCacheEnabled) { mediaFileMemoryCache.put(new Element(file, mediaFile)); } } private MediaFile getFromMemoryCache(File file) { if (!memoryCacheEnabled) { return null; } Element element = mediaFileMemoryCache.get(file); return element == null ? null : (MediaFile) element.getObjectValue(); } public void setMemoryCacheEnabled(boolean memoryCacheEnabled) { this.memoryCacheEnabled = memoryCacheEnabled; if (!memoryCacheEnabled) { mediaFileMemoryCache.removeAll(); } } /** * 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[] candidates) throws IOException { for (String mask : settingsService.getCoverArtFileTypesAsArray()) { for (File candidate : candidates) { if (candidate.isFile() && candidate.getName().toUpperCase().endsWith(mask.toUpperCase()) && !candidate.getName().startsWith(".")) { return candidate; } } } // Look for embedded images in audiofiles. (Only check first audio file encountered). for (File candidate : candidates) { if (parser.isApplicable(candidate)) { if (parser.isImageAvailable(getMediaFile(candidate))) { return candidate; } else { return null; } } } return null; } 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 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 getDescendantsOf(MediaFile ancestor, boolean sort) { if (ancestor.isFile()) { return Arrays.asList(ancestor); } List result = new ArrayList(); 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 and album. */ public void incrementPlayCount(MediaFile file) { Date now = new Date(); file.setLastPlayed(now); file.setPlayCount(file.getPlayCount() + 1); updateMediaFile(file); MediaFile parent = getParentOf(file); if (!isRoot(parent)) { parent.setLastPlayed(now); parent.setPlayCount(parent.getPlayCount() + 1); updateMediaFile(parent); } Album album = albumDao.getAlbum(file.getAlbumArtist(), file.getAlbumName()); if (album != null) { album.setLastPlayed(now); album.setPlayCount(album.getPlayCount() + 1); albumDao.createOrUpdateAlbum(album); } } public int getAlbumCount(List musicFolders) { return mediaFileDao.getAlbumCount(musicFolders); } public int getPlayedAlbumCount(List musicFolders) { return mediaFileDao.getPlayedAlbumCount(musicFolders); } public int getStarredAlbumCount(String username, List musicFolders) { return mediaFileDao.getStarredAlbumCount(username, musicFolders); } public void clearMemoryCache() { mediaFileMemoryCache.removeAll(); } public void setAlbumDao(AlbumDao albumDao) { this.albumDao = albumDao; } public void setParser(JaudiotaggerParser parser) { this.parser = parser; } }