From a1a18f77a50804e0127dfa4b0f5240c49c541184 Mon Sep 17 00:00:00 2001 From: Scott Jackson Date: Mon, 2 Jul 2012 21:24:02 -0700 Subject: Initial Commit --- .../subsonic/service/PodcastService.java | 599 +++++++++++++++++++++ 1 file changed, 599 insertions(+) create mode 100644 subsonic-main/src/main/java/net/sourceforge/subsonic/service/PodcastService.java (limited to 'subsonic-main/src/main/java/net/sourceforge/subsonic/service/PodcastService.java') diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/PodcastService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/PodcastService.java new file mode 100644 index 00000000..09184df6 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/PodcastService.java @@ -0,0 +1,599 @@ +/* + 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; + } +} -- cgit v1.2.3