/*
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 java.io.File;
import java.io.IOException;
import java.util.*;
/**
* Provides services for instantiating and caching media files and cover art.
*
* @author Sindre Mehus
*/
public class MediaFileService {
private static final Logger LOG = LoggerFactory.getLogger(MediaFileService.class);
private Ehcache mediaFileMemoryCache;
private SecurityService securityService;
private SettingsService settingsService;
private MediaFileDao mediaFileDao;
private AlbumDao albumDao;
private JaudiotaggerParser parser;
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;
}
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 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());
}
/**
* 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.
*
* @param criteria Random search criteria.
* @param count Max number of songs to return.
* @return Random songs
* @see SearchService.getRandomSongs
*/
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) {
// Exclude all hidden files starting with a single "." or "@eaDir" (thumbnail dir created on Synology devices).
String name = file.getName();
return (name.startsWith(".") && !name.startsWith("..")) || name.startsWith("@eaDir") || name.equals("Thumbs.db");
}
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());
}
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;
}
}