/*
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 .
Copyright 2009 (C) Sindre Mehus
*/
package net.sourceforge.subsonic.service;
import net.sourceforge.subsonic.Logger;
import net.sourceforge.subsonic.dao.PodcastDao;
import net.sourceforge.subsonic.domain.MediaFile;
import net.sourceforge.subsonic.domain.PodcastChannel;
import net.sourceforge.subsonic.domain.PodcastEpisode;
import net.sourceforge.subsonic.domain.PodcastStatus;
import net.sourceforge.subsonic.util.StringUtil;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.params.HttpConnectionParams;
import org.jdom.Document;
import org.jdom.Element;
import org.jdom.Namespace;
import org.jdom.input.SAXBuilder;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
/**
* Provides services for Podcast reception.
*
* @author Sindre Mehus
*/
public class PodcastService {
private static final Logger LOG = Logger.getLogger(PodcastService.class);
private static final DateFormat[] RSS_DATE_FORMATS = {new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US),
new SimpleDateFormat("dd MMM yyyy HH:mm:ss Z", Locale.US)};
private static final Namespace[] ITUNES_NAMESPACES = {Namespace.getNamespace("http://www.itunes.com/DTDs/Podcast-1.0.dtd"),
Namespace.getNamespace("http://www.itunes.com/dtds/podcast-1.0.dtd")};
private final ExecutorService refreshExecutor;
private final ExecutorService downloadExecutor;
private final ScheduledExecutorService scheduledExecutor;
private ScheduledFuture> scheduledRefresh;
private PodcastDao podcastDao;
private SettingsService settingsService;
private SecurityService securityService;
private MediaFileService mediaFileService;
public PodcastService() {
ThreadFactory threadFactory = new ThreadFactory() {
public Thread newThread(Runnable r) {
Thread t = Executors.defaultThreadFactory().newThread(r);
t.setDaemon(true);
return t;
}
};
refreshExecutor = Executors.newFixedThreadPool(5, threadFactory);
downloadExecutor = Executors.newFixedThreadPool(3, threadFactory);
scheduledExecutor = Executors.newSingleThreadScheduledExecutor(threadFactory);
}
public synchronized void init() {
// Clean up partial downloads.
for (PodcastChannel channel : getAllChannels()) {
for (PodcastEpisode episode : getEpisodes(channel.getId(), false)) {
if (episode.getStatus() == PodcastStatus.DOWNLOADING) {
deleteEpisode(episode.getId(), false);
LOG.info("Deleted Podcast episode '" + episode.getTitle() + "' since download was interrupted.");
}
}
}
schedule();
}
public synchronized void schedule() {
Runnable task = new Runnable() {
public void run() {
LOG.info("Starting scheduled Podcast refresh.");
refreshAllChannels(true);
LOG.info("Completed scheduled Podcast refresh.");
}
};
if (scheduledRefresh != null) {
scheduledRefresh.cancel(true);
}
int hoursBetween = settingsService.getPodcastUpdateInterval();
if (hoursBetween == -1) {
LOG.info("Automatic Podcast update disabled.");
return;
}
long periodMillis = hoursBetween * 60L * 60L * 1000L;
long initialDelayMillis = 5L * 60L * 1000L;
scheduledRefresh = scheduledExecutor.scheduleAtFixedRate(task, initialDelayMillis, periodMillis, TimeUnit.MILLISECONDS);
Date firstTime = new Date(System.currentTimeMillis() + initialDelayMillis);
LOG.info("Automatic Podcast update scheduled to run every " + hoursBetween + " hour(s), starting at " + firstTime);
}
/**
* Creates a new Podcast channel.
*
* @param url The URL of the Podcast channel.
*/
public void createChannel(String url) {
url = sanitizeUrl(url);
PodcastChannel channel = new PodcastChannel(url);
int channelId = podcastDao.createChannel(channel);
refreshChannels(Arrays.asList(getChannel(channelId)), true);
}
private String sanitizeUrl(String url) {
return url.replace(" ", "%20");
}
private PodcastChannel getChannel(int channelId) {
for (PodcastChannel channel : getAllChannels()) {
if (channelId == channel.getId()) {
return channel;
}
}
return null;
}
/**
* Returns all Podcast channels.
*
* @return Possibly empty list of all Podcast channels.
*/
public List getAllChannels() {
return podcastDao.getAllChannels();
}
/**
* Returns all Podcast episodes for a given channel.
*
* @param channelId The Podcast channel ID.
* @param includeDeleted Whether to include logically deleted episodes in the result.
* @return Possibly empty list of all Podcast episodes for the given channel, sorted in
* reverse chronological order (newest episode first).
*/
public List getEpisodes(int channelId, boolean includeDeleted) {
List all = podcastDao.getEpisodes(channelId);
addMediaFileIdToEpisodes(all);
if (includeDeleted) {
return all;
}
List filtered = new ArrayList();
for (PodcastEpisode episode : all) {
if (episode.getStatus() != PodcastStatus.DELETED) {
filtered.add(episode);
}
}
return filtered;
}
public PodcastEpisode getEpisode(int episodeId, boolean includeDeleted) {
PodcastEpisode episode = podcastDao.getEpisode(episodeId);
if (episode == null) {
return null;
}
if (episode.getStatus() == PodcastStatus.DELETED && !includeDeleted) {
return null;
}
addMediaFileIdToEpisodes(Arrays.asList(episode));
return episode;
}
private void addMediaFileIdToEpisodes(List episodes) {
for (PodcastEpisode episode : episodes) {
if (episode.getPath() != null) {
MediaFile mediaFile = mediaFileService.getMediaFile(episode.getPath());
if (mediaFile != null) {
episode.setMediaFileId(mediaFile.getId());
}
}
}
}
private PodcastEpisode getEpisode(int channelId, String url) {
if (url == null) {
return null;
}
for (PodcastEpisode episode : getEpisodes(channelId, true)) {
if (url.equals(episode.getUrl())) {
return episode;
}
}
return null;
}
public void refreshAllChannels(boolean downloadEpisodes) {
refreshChannels(getAllChannels(), downloadEpisodes);
}
private void refreshChannels(final List channels, final boolean downloadEpisodes) {
for (final PodcastChannel channel : channels) {
Runnable task = new Runnable() {
public void run() {
doRefreshChannel(channel, downloadEpisodes);
}
};
refreshExecutor.submit(task);
}
}
@SuppressWarnings({"unchecked"})
private void doRefreshChannel(PodcastChannel channel, boolean downloadEpisodes) {
InputStream in = null;
HttpClient client = new DefaultHttpClient();
try {
channel.setStatus(PodcastStatus.DOWNLOADING);
channel.setErrorMessage(null);
podcastDao.updateChannel(channel);
HttpConnectionParams.setConnectionTimeout(client.getParams(), 2 * 60 * 1000); // 2 minutes
HttpConnectionParams.setSoTimeout(client.getParams(), 10 * 60 * 1000); // 10 minutes
HttpGet method = new HttpGet(channel.getUrl());
HttpResponse response = client.execute(method);
in = response.getEntity().getContent();
Document document = new SAXBuilder().build(in);
Element channelElement = document.getRootElement().getChild("channel");
channel.setTitle(channelElement.getChildTextTrim("title"));
channel.setDescription(channelElement.getChildTextTrim("description"));
channel.setStatus(PodcastStatus.COMPLETED);
channel.setErrorMessage(null);
podcastDao.updateChannel(channel);
refreshEpisodes(channel, channelElement.getChildren("item"));
} catch (Exception x) {
LOG.warn("Failed to get/parse RSS file for Podcast channel " + channel.getUrl(), x);
channel.setStatus(PodcastStatus.ERROR);
channel.setErrorMessage(x.toString());
podcastDao.updateChannel(channel);
} finally {
IOUtils.closeQuietly(in);
client.getConnectionManager().shutdown();
}
if (downloadEpisodes) {
for (final PodcastEpisode episode : getEpisodes(channel.getId(), false)) {
if (episode.getStatus() == PodcastStatus.NEW && episode.getUrl() != null) {
downloadEpisode(episode);
}
}
}
}
public void downloadEpisode(final PodcastEpisode episode) {
Runnable task = new Runnable() {
public void run() {
doDownloadEpisode(episode);
}
};
downloadExecutor.submit(task);
}
private void refreshEpisodes(PodcastChannel channel, List episodeElements) {
List episodes = new ArrayList();
for (Element episodeElement : episodeElements) {
String title = episodeElement.getChildTextTrim("title");
String duration = getITunesElement(episodeElement, "duration");
String description = episodeElement.getChildTextTrim("description");
if (StringUtils.isBlank(description)) {
description = getITunesElement(episodeElement, "summary");
}
Element enclosure = episodeElement.getChild("enclosure");
if (enclosure == null) {
LOG.debug("No enclosure found for episode " + title);
continue;
}
String url = enclosure.getAttributeValue("url");
url = sanitizeUrl(url);
if (url == null) {
LOG.debug("No enclosure URL found for episode " + title);
continue;
}
if (getEpisode(channel.getId(), url) == null) {
Long length = null;
try {
length = new Long(enclosure.getAttributeValue("length"));
} catch (Exception x) {
LOG.warn("Failed to parse enclosure length.", x);
}
Date date = parseDate(episodeElement.getChildTextTrim("pubDate"));
PodcastEpisode episode = new PodcastEpisode(null, channel.getId(), url, null, title, description, date,
duration, length, 0L, PodcastStatus.NEW, null);
episodes.add(episode);
LOG.info("Created Podcast episode " + title);
}
}
// Sort episode in reverse chronological order (newest first)
Collections.sort(episodes, new Comparator() {
public int compare(PodcastEpisode a, PodcastEpisode b) {
long timeA = a.getPublishDate() == null ? 0L : a.getPublishDate().getTime();
long timeB = b.getPublishDate() == null ? 0L : b.getPublishDate().getTime();
if (timeA < timeB) {
return 1;
}
if (timeA > timeB) {
return -1;
}
return 0;
}
});
// Create episodes in database, skipping the proper number of episodes.
int downloadCount = settingsService.getPodcastEpisodeDownloadCount();
if (downloadCount == -1) {
downloadCount = Integer.MAX_VALUE;
}
for (int i = 0; i < episodes.size(); i++) {
PodcastEpisode episode = episodes.get(i);
if (i >= downloadCount) {
episode.setStatus(PodcastStatus.SKIPPED);
}
podcastDao.createEpisode(episode);
}
}
private Date parseDate(String s) {
for (DateFormat dateFormat : RSS_DATE_FORMATS) {
try {
return dateFormat.parse(s);
} catch (Exception x) {
// Ignored.
}
}
LOG.warn("Failed to parse publish date: '" + s + "'.");
return null;
}
private String getITunesElement(Element element, String childName) {
for (Namespace ns : ITUNES_NAMESPACES) {
String value = element.getChildTextTrim(childName, ns);
if (value != null) {
return value;
}
}
return null;
}
private void doDownloadEpisode(PodcastEpisode episode) {
InputStream in = null;
OutputStream out = null;
if (getEpisode(episode.getId(), false) == null) {
LOG.info("Podcast " + episode.getUrl() + " was deleted. Aborting download.");
return;
}
LOG.info("Starting to download Podcast from " + episode.getUrl());
HttpClient client = new DefaultHttpClient();
try {
PodcastChannel channel = getChannel(episode.getChannelId());
HttpConnectionParams.setConnectionTimeout(client.getParams(), 2 * 60 * 1000); // 2 minutes
HttpConnectionParams.setSoTimeout(client.getParams(), 10 * 60 * 1000); // 10 minutes
HttpGet method = new HttpGet(episode.getUrl());
HttpResponse response = client.execute(method);
in = response.getEntity().getContent();
File file = getFile(channel, episode);
out = new FileOutputStream(file);
episode.setStatus(PodcastStatus.DOWNLOADING);
episode.setBytesDownloaded(0L);
episode.setErrorMessage(null);
episode.setPath(file.getPath());
podcastDao.updateEpisode(episode);
byte[] buffer = new byte[4096];
long bytesDownloaded = 0;
int n;
long nextLogCount = 30000L;
while ((n = in.read(buffer)) != -1) {
out.write(buffer, 0, n);
bytesDownloaded += n;
if (bytesDownloaded > nextLogCount) {
episode.setBytesDownloaded(bytesDownloaded);
nextLogCount += 30000L;
if (getEpisode(episode.getId(), false) == null) {
break;
}
podcastDao.updateEpisode(episode);
}
}
if (getEpisode(episode.getId(), false) == null) {
LOG.info("Podcast " + episode.getUrl() + " was deleted. Aborting download.");
IOUtils.closeQuietly(out);
file.delete();
} else {
episode.setBytesDownloaded(bytesDownloaded);
podcastDao.updateEpisode(episode);
LOG.info("Downloaded " + bytesDownloaded + " bytes from Podcast " + episode.getUrl());
IOUtils.closeQuietly(out);
episode.setStatus(PodcastStatus.COMPLETED);
podcastDao.updateEpisode(episode);
deleteObsoleteEpisodes(channel);
}
} catch (Exception x) {
LOG.warn("Failed to download Podcast from " + episode.getUrl(), x);
episode.setStatus(PodcastStatus.ERROR);
episode.setErrorMessage(x.toString());
podcastDao.updateEpisode(episode);
} finally {
IOUtils.closeQuietly(in);
IOUtils.closeQuietly(out);
client.getConnectionManager().shutdown();
}
}
private synchronized void deleteObsoleteEpisodes(PodcastChannel channel) {
int episodeCount = settingsService.getPodcastEpisodeRetentionCount();
if (episodeCount == -1) {
return;
}
List episodes = getEpisodes(channel.getId(), false);
// Don't do anything if other episodes of the same channel is currently downloading.
for (PodcastEpisode episode : episodes) {
if (episode.getStatus() == PodcastStatus.DOWNLOADING) {
return;
}
}
// Reverse array to get chronological order (oldest episodes first).
Collections.reverse(episodes);
int episodesToDelete = Math.max(0, episodes.size() - episodeCount);
for (int i = 0; i < episodesToDelete; i++) {
deleteEpisode(episodes.get(i).getId(), true);
LOG.info("Deleted old Podcast episode " + episodes.get(i).getUrl());
}
}
private synchronized File getFile(PodcastChannel channel, PodcastEpisode episode) {
File podcastDir = new File(settingsService.getPodcastFolder());
File channelDir = new File(podcastDir, StringUtil.fileSystemSafe(channel.getTitle()));
if (!channelDir.exists()) {
boolean ok = channelDir.mkdirs();
if (!ok) {
throw new RuntimeException("Failed to create directory " + channelDir);
}
MediaFile mediaFile = mediaFileService.getMediaFile(channelDir);
mediaFile.setComment(channel.getDescription());
mediaFileService.updateMediaFile(mediaFile);
}
String filename = StringUtil.getUrlFile(episode.getUrl());
if (filename == null) {
filename = episode.getTitle();
}
filename = StringUtil.fileSystemSafe(filename);
String extension = FilenameUtils.getExtension(filename);
filename = FilenameUtils.removeExtension(filename);
if (StringUtils.isBlank(extension)) {
extension = "mp3";
}
File file = new File(channelDir, filename + "." + extension);
for (int i = 0; file.exists(); i++) {
file = new File(channelDir, filename + i + "." + extension);
}
if (!securityService.isWriteAllowed(file)) {
throw new SecurityException("Access denied to file " + file);
}
return file;
}
/**
* Deletes the Podcast channel with the given ID.
*
* @param channelId The Podcast channel ID.
*/
public void deleteChannel(int channelId) {
// Delete all associated episodes (in case they have files that need to be deleted).
List episodes = getEpisodes(channelId, false);
for (PodcastEpisode episode : episodes) {
deleteEpisode(episode.getId(), false);
}
podcastDao.deleteChannel(channelId);
}
/**
* Deletes the Podcast episode with the given ID.
*
* @param episodeId The Podcast episode ID.
* @param logicalDelete Whether to perform a logical delete by setting the
* episode status to {@link PodcastStatus#DELETED}.
*/
public void deleteEpisode(int episodeId, boolean logicalDelete) {
PodcastEpisode episode = podcastDao.getEpisode(episodeId);
if (episode == null) {
return;
}
// Delete file.
if (episode.getPath() != null) {
File file = new File(episode.getPath());
if (file.exists()) {
file.delete();
// TODO: Delete directory if empty?
}
}
if (logicalDelete) {
episode.setStatus(PodcastStatus.DELETED);
episode.setErrorMessage(null);
podcastDao.updateEpisode(episode);
} else {
podcastDao.deleteEpisode(episodeId);
}
}
public void setPodcastDao(PodcastDao podcastDao) {
this.podcastDao = podcastDao;
}
public void setSettingsService(SettingsService settingsService) {
this.settingsService = settingsService;
}
public void setSecurityService(SecurityService securityService) {
this.securityService = securityService;
}
public void setMediaFileService(MediaFileService mediaFileService) {
this.mediaFileService = mediaFileService;
}
}