content: /*
   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 <http://www.gnu.org/licenses/>.
  
   Copyright 2016 (C) Airsonic Authors
   Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
   */
  package org.airsonic.player.service;
  
  import com.google.common.collect.Iterables;
  import com.google.common.collect.Lists;
  import org.airsonic.player.dao.PodcastDao;
  import org.airsonic.player.domain.MediaFile;
  import org.airsonic.player.domain.PodcastChannel;
  import org.airsonic.player.domain.PodcastEpisode;
  import org.airsonic.player.domain.PodcastStatus;
  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.airsonic.player.util.StringUtil;
  import org.apache.commons.io.FilenameUtils;
  import org.apache.commons.io.IOUtils;
  import org.apache.commons.lang.StringUtils;
  import org.apache.http.Header;
  import org.apache.http.HttpResponse;
  import org.apache.http.client.config.CookieSpecs;
  import org.apache.http.client.config.RequestConfig;
  import org.apache.http.client.methods.CloseableHttpResponse;
  import org.apache.http.client.methods.HttpGet;
  import org.apache.http.entity.ContentType;
  import org.apache.http.impl.client.CloseableHttpClient;
  import org.apache.http.impl.client.HttpClients;
  import org.jdom2.Document;
  import org.jdom2.Element;
  import org.jdom2.Namespace;
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  import org.springframework.beans.factory.annotation.Autowired;
  import org.springframework.stereotype.Service;
  
  import javax.annotation.PostConstruct;
  
  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.*;
  import java.util.concurrent.*;
  
  import static org.airsonic.player.util.XMLUtil.createSAXBuilder;
  
  /**
   * Provides services for Podcast reception.
   *
   * @author Sindre Mehus
   */
  @Service
  public class PodcastService {
  
      private static final Logger LOG = LoggerFactory.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;
      @Autowired
      private PodcastDao podcastDao;
      @Autowired
      private SettingsService settingsService;
      @Autowired
      private SecurityService securityService;
      @Autowired
      private MediaFileService mediaFileService;
      @Autowired
      private MetaDataParserFactory metaDataParserFactory;
  
      public PodcastService() {
          ThreadFactory threadFactory = 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);
      }
  
      @PostConstruct
      public synchronized void init() {
          try {
              // Clean up partial downloads.
              for (PodcastChannel channel : getAllChannels()) {
                  for (PodcastEpisode episode : getEpisodes(channel.getId())) {
                      if (episode.getStatus() == PodcastStatus.DOWNLOADING) {
                          deleteEpisode(episode.getId(), false);
                          LOG.info("Deleted Podcast episode '" + episode.getTitle() + "' since download was interrupted.");
                      }
                  }
              }
              schedule();
          } catch (Throwable x) {
              LOG.error("Failed to initialize PodcastService: " + x, x);
          }
      }
  
      public synchronized void schedule() {
          Runnable task = () -> {
              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");
      }
  
      /**
       * Returns a single Podcast channel.
       */
      public PodcastChannel getChannel(int channelId) {
          PodcastChannel channel = podcastDao.getChannel(channelId);
          if (channel.getTitle() != null)
              addMediaFileIdToChannels(Arrays.asList(channel));
          return channel;
      }
  
      /**
       * Returns all Podcast channels.
       *
       * @return Possibly empty list of all Podcast channels.
       */
      public List<PodcastChannel> getAllChannels() {
          return addMediaFileIdToChannels(podcastDao.getAllChannels());
      }
  
      private PodcastEpisode getEpisodeByUrl(String url) {
          PodcastEpisode episode = podcastDao.getEpisodeByUrl(url);
          if (episode == null) {
              return null;
          }
          List<PodcastEpisode> episodes = Arrays.asList(episode);
          episodes = filterAllowed(episodes);
          addMediaFileIdToEpisodes(episodes);
          return episodes.isEmpty() ? null : episodes.get(0);
      }
  
      /**
       * Returns all Podcast episodes for a given channel.
       *
       * @param channelId      The Podcast channel ID.
       * @return Possibly empty list of all Podcast episodes for the given channel, sorted in
       *         reverse chronological order (newest episode first).
       */
      public List<PodcastEpisode> getEpisodes(int channelId) {
          List<PodcastEpisode> episodes = filterAllowed(podcastDao.getEpisodes(channelId));
          return addMediaFileIdToEpisodes(episodes);
      }
  
      /**
       * Returns the N newest episodes.
       *
       * @return Possibly empty list of the newest Podcast episodes, sorted in
       *         reverse chronological order (newest episode first).
       */
      public List<PodcastEpisode> getNewestEpisodes(int count) {
          List<PodcastEpisode> episodes = addMediaFileIdToEpisodes(podcastDao.getNewestEpisodes(count));
  
          return Lists.newArrayList(Iterables.filter(episodes, episode -> {
              Integer mediaFileId = episode.getMediaFileId();
              if (mediaFileId == null) {
                  return false;
              }
              MediaFile mediaFile = mediaFileService.getMediaFile(mediaFileId);
              return mediaFile != null && mediaFile.isPresent();
          }));
      }
  
      private List<PodcastEpisode> filterAllowed(List<PodcastEpisode> episodes) {
          List<PodcastEpisode> result = new ArrayList<>(episodes.size());
          for (PodcastEpisode episode : episodes) {
              if (episode.getPath() == null || securityService.isReadAllowed(new File(episode.getPath()))) {
                  result.add(episode);
              }
          }
          return result;
      }
  
      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 List<PodcastEpisode> addMediaFileIdToEpisodes(List<PodcastEpisode> episodes) {
          for (PodcastEpisode episode : episodes) {
              if (episode.getPath() != null) {
                  MediaFile mediaFile = mediaFileService.getMediaFile(episode.getPath());
                  if (mediaFile != null && mediaFile.isPresent()) {
                      episode.setMediaFileId(mediaFile.getId());
                  }
              }
          }
          return episodes;
      }
  
      private List<PodcastChannel> addMediaFileIdToChannels(List<PodcastChannel> channels) {
          for (PodcastChannel channel : channels) {
              try {
                  if (channel.getTitle() == null) {
                      LOG.warn("Podcast channel id {} has null title", channel.getId());
                      continue;
                  }
                  File dir = getChannelDirectory(channel);
                  MediaFile mediaFile = mediaFileService.getMediaFile(dir);
                  if (mediaFile != null) {
                      channel.setMediaFileId(mediaFile.getId());
                  }
              } catch (Exception x) {
                  LOG.warn("Failed to resolve media file ID for podcast channel '" + channel.getTitle() + "': " + x, x);
              }
          }
          return channels;
      }
  
      public void refreshChannel(int channelId, boolean downloadEpisodes) {
          refreshChannels(Arrays.asList(getChannel(channelId)), downloadEpisodes);
      }
  
      public void refreshAllChannels(boolean downloadEpisodes) {
          refreshChannels(getAllChannels(), downloadEpisodes);
      }
  
      private void refreshChannels(final List<PodcastChannel> channels, final boolean downloadEpisodes) {
          for (final PodcastChannel channel : channels) {
              Runnable task = () -> doRefreshChannel(channel, downloadEpisodes);
              refreshExecutor.submit(task);
          }
      }
  
      private void doRefreshChannel(PodcastChannel channel, boolean downloadEpisodes) {
          InputStream in = null;
  
          try (CloseableHttpClient client = HttpClients.createDefault()) {
              channel.setStatus(PodcastStatus.DOWNLOADING);
              channel.setErrorMessage(null);
              podcastDao.updateChannel(channel);
              RequestConfig requestConfig = RequestConfig.custom()
                      .setConnectTimeout(2 * 60 * 1000) // 2 minutes
                      .setSocketTimeout(10 * 60 * 1000) // 10 minutes
                      .build();
              HttpGet method = new HttpGet(channel.getUrl());
              method.setConfig(requestConfig);
  
              try (CloseableHttpResponse response = client.execute(method)) {
                  in = response.getEntity().getContent();
  
                  Document document = createSAXBuilder().build(in);
                  Element channelElement = document.getRootElement().getChild("channel");
  
                  channel.setTitle(StringUtil.removeMarkup(channelElement.getChildTextTrim("title")));
                  channel.setDescription(StringUtil.removeMarkup(channelElement.getChildTextTrim("description")));
                  channel.setImageUrl(getChannelImageUrl(channelElement));
                  channel.setStatus(PodcastStatus.COMPLETED);
                  channel.setErrorMessage(null);
                  podcastDao.updateChannel(channel);
  
                  downloadImage(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(getErrorMessage(x));
              podcastDao.updateChannel(channel);
          } finally {
              FileUtil.closeQuietly(in);
          }
  
          if (downloadEpisodes) {
              for (final PodcastEpisode episode : getEpisodes(channel.getId())) {
                  if (episode.getStatus() == PodcastStatus.NEW && episode.getUrl() != null) {
                      downloadEpisode(episode);
                  }
              }
          }
      }
  
      private void downloadImage(PodcastChannel channel) {
          InputStream in = null;
          OutputStream out = null;
          try (CloseableHttpClient client = HttpClients.createDefault()) {
              String imageUrl = channel.getImageUrl();
              if (imageUrl == null) {
                  return;
              }
  
              File dir = getChannelDirectory(channel);
              MediaFile channelMediaFile = mediaFileService.getMediaFile(dir);
              File existingCoverArt = mediaFileService.getCoverArt(channelMediaFile);
              boolean imageFileExists = existingCoverArt != null && mediaFileService.getMediaFile(existingCoverArt) == null;
              if (imageFileExists) {
                  return;
              }
  
              HttpGet method = new HttpGet(imageUrl);
              try (CloseableHttpResponse response = client.execute(method)) {
                  in = response.getEntity().getContent();
                  out = new FileOutputStream(new File(dir, "cover." + getCoverArtSuffix(response)));
                  IOUtils.copy(in, out);
                  mediaFileService.refreshMediaFile(channelMediaFile);
              }
          } catch (Exception x) {
              LOG.warn("Failed to download cover art for podcast channel '" + channel.getTitle() + "': " + x, x);
          } finally {
              FileUtil.closeQuietly(in);
              FileUtil.closeQuietly(out);
          }
      }
  
      private String getCoverArtSuffix(HttpResponse response) {
          String result = null;
          Header contentTypeHeader = response.getEntity().getContentType();
          if (contentTypeHeader != null && contentTypeHeader.getValue() != null) {
              ContentType contentType = ContentType.parse(contentTypeHeader.getValue());
              String mimeType = contentType.getMimeType();
              result = StringUtil.getSuffix(mimeType);
          }
          return result == null ? "jpeg" : result;
      }
  
      private String getChannelImageUrl(Element channelElement) {
          String result = getITunesAttribute(channelElement, "image", "href");
          if (result == null) {
              Element imageElement = channelElement.getChild("image");
              if (imageElement != null) {
                  result = imageElement.getChildTextTrim("url");
              }
          }
          return result;
      }
  
      private String getErrorMessage(Exception x) {
          return x.getMessage() != null ? x.getMessage() : x.toString();
      }
  
      public void downloadEpisode(final PodcastEpisode episode) {
          Runnable task = () -> doDownloadEpisode(episode);
          downloadExecutor.submit(task);
      }
  
      private void refreshEpisodes(PodcastChannel channel, List<Element> episodeElements) {
  
          List<PodcastEpisode> episodes = new ArrayList<>();
  
          for (Element episodeElement : episodeElements) {
  
              String title = episodeElement.getChildTextTrim("title");
              String duration = formatDuration(getITunesElement(episodeElement, "duration"));
              String description = episodeElement.getChildTextTrim("description");
              if (StringUtils.isBlank(description)) {
                  description = getITunesElement(episodeElement, "summary");
              }
              title = StringUtil.removeMarkup(title);
              description = StringUtil.removeMarkup(description);
  
              Element enclosure = episodeElement.getChild("enclosure");
              if (enclosure == null) {
                  LOG.info("No enclosure found for episode " + title);
                  continue;
              }
  
              String url = enclosure.getAttributeValue("url");
              url = sanitizeUrl(url);
              if (url == null) {
                  LOG.info("No enclosure URL found for episode " + title);
                  continue;
              }
  
              if (getEpisodeByUrl(url) == null) {
                  Long length = null;
                  try {
                      length = Long.valueOf(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)
          episodes.sort((a, b) -> {
              long timeA = a.getPublishDate() == null ? 0L : a.getPublishDate().getTime();
              long timeB = b.getPublishDate() == null ? 0L : b.getPublishDate().getTime();
  
              return Long.compare(timeB, timeA);
          });
  
          // 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 formatDuration(String duration) {
          if (duration == null) return null;
          if (duration.matches("^\\d+$")) {
              long seconds = Long.valueOf(duration);
              if (seconds >= 3600)
                  return String.format("%02d:%02d:%02d", seconds / 3600, seconds / 60, seconds % 60);
              else
                  return String.format("%02d:%02d", seconds / 60, seconds % 60);
          } else {
              return duration;
          }
      }
  
      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 String getITunesAttribute(Element element, String childName, String attributeName) {
          for (Namespace ns : ITUNES_NAMESPACES) {
              Element elem = element.getChild(childName, ns);
              if (elem != null) {
                  return StringUtils.trimToNull(elem.getAttributeValue(attributeName));
              }
          }
          return null;
      }
  
      private void doDownloadEpisode(PodcastEpisode episode) {
          InputStream in = null;
          OutputStream out = null;
  
          if (isEpisodeDeleted(episode)) {
              LOG.info("Podcast " + episode.getUrl() + " was deleted. Aborting download.");
              return;
          }
  
          LOG.info("Starting to download Podcast from " + episode.getUrl());
  
          try (CloseableHttpClient client = HttpClients.createDefault()) {
  
              PodcastChannel channel = getChannel(episode.getChannelId());
              RequestConfig requestConfig = RequestConfig.custom()
                      .setConnectTimeout(2 * 60 * 1000) // 2 minutes
                      .setSocketTimeout(10 * 60 * 1000) // 10 minutes
                      // Workaround HttpClient circular redirects, which some feeds use (with query parameters)
                      .setCircularRedirectsAllowed(true)
                      // Workaround HttpClient not understanding latest RFC-compliant cookie 'expires' attributes
                      .setCookieSpec(CookieSpecs.STANDARD)
                      .build();
              HttpGet method = new HttpGet(episode.getUrl());
              method.setConfig(requestConfig);
  
              try (CloseableHttpResponse 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;
  
                          // Abort download if episode was deleted by user.
                          if (isEpisodeDeleted(episode)) {
                              break;
                          }
                          podcastDao.updateEpisode(episode);
                      }
                  }
  
                  if (isEpisodeDeleted(episode)) {
                      LOG.info("Podcast " + episode.getUrl() + " was deleted. Aborting download.");
                      FileUtil.closeQuietly(out);
                      if (!file.delete()) {
                          LOG.warn("Unable to delete " + file);
                      }
                  } else {
                      addMediaFileIdToEpisodes(Arrays.asList(episode));
                      episode.setBytesDownloaded(bytesDownloaded);
                      podcastDao.updateEpisode(episode);
                      LOG.info("Downloaded " + bytesDownloaded + " bytes from Podcast " + episode.getUrl());
                      FileUtil.closeQuietly(out);
                      updateTags(file, episode);
                      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(getErrorMessage(x));
              podcastDao.updateEpisode(episode);
          } finally {
              FileUtil.closeQuietly(in);
              FileUtil.closeQuietly(out);
          }
      }
  
      private boolean isEpisodeDeleted(PodcastEpisode episode) {
          episode = podcastDao.getEpisode(episode.getId());
          return episode == null || episode.getStatus() == PodcastStatus.DELETED;
      }
  
      private void updateTags(File file, PodcastEpisode episode) {
          try {
              MediaFile mediaFile = mediaFileService.getMediaFile(file, false);
              if (StringUtils.isNotBlank(episode.getTitle())) {
                  MetaDataParser parser = metaDataParserFactory.getParser(file);
                  if (!parser.isEditingSupported()) {
                      return;
                  }
                  MetaData metaData = parser.getRawMetaData(file);
                  metaData.setTitle(episode.getTitle());
                  parser.setMetaData(mediaFile, metaData);
                  mediaFileService.refreshMediaFile(mediaFile);
              }
          } catch (Exception x) {
              LOG.warn("Failed to update tags for podcast " + episode.getUrl(), x);
          }
      }
  
      private synchronized void deleteObsoleteEpisodes(PodcastChannel channel) {
          int episodeCount = settingsService.getPodcastEpisodeRetentionCount();
          if (episodeCount == -1) {
              return;
          }
  
          List<PodcastEpisode> episodes = getEpisodes(channel.getId());
  
          // 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 channelDir = getChannelDirectory(channel);
  
          String episodeDate = String.format("%tF", episode.getPublishDate());
          String filename = channel.getTitle() + " - " + episodeDate + " - " + episode.getId() + " - " + episode.getTitle();
          filename = filename.substring(0, Math.min(filename.length(), 146));
  
          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;
      }
  
      private File getChannelDirectory(PodcastChannel channel) {
          File podcastDir = new File(settingsService.getPodcastFolder());
          File channelDir = new File(podcastDir, StringUtil.fileSystemSafe(channel.getTitle()));
  
          if (!podcastDir.canWrite()) {
              throw new RuntimeException("The podcasts directory " + podcastDir + " isn't writeable.");
          }
  
          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);
          }
          return channelDir;
      }
  
      /**
       * 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<PodcastEpisode> episodes = getEpisodes(channelId);
          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;
      }
  
      public void setMetaDataParserFactory(MetaDataParserFactory metaDataParserFactory) {
          this.metaDataParserFactory = metaDataParserFactory;
      }
  }
  