diff --git a/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs b/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs index 5cac732..c59b841 100644 --- a/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs +++ b/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs @@ -180,6 +180,11 @@ public class JellypodController : ControllerBase podcast.MaxEpisodesToKeep = request.MaxEpisodesToKeep.Value; } + if (request.MaxEpisodeAgeDays.HasValue) + { + podcast.MaxEpisodeAgeDays = request.MaxEpisodeAgeDays.Value; + } + await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false); return Ok(podcast); } diff --git a/Jellyfin.Plugin.Jellypod/Api/Models/UpdatePodcastRequest.cs b/Jellyfin.Plugin.Jellypod/Api/Models/UpdatePodcastRequest.cs index 8c4cb04..2672d24 100644 --- a/Jellyfin.Plugin.Jellypod/Api/Models/UpdatePodcastRequest.cs +++ b/Jellyfin.Plugin.Jellypod/Api/Models/UpdatePodcastRequest.cs @@ -14,4 +14,9 @@ public class UpdatePodcastRequest /// Gets or sets the maximum episodes to keep. /// public int? MaxEpisodesToKeep { get; set; } + + /// + /// Gets or sets the maximum age in days for episodes (0 = use global, -1 = unlimited). + /// + public int? MaxEpisodeAgeDays { get; set; } } diff --git a/Jellyfin.Plugin.Jellypod/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Jellypod/Configuration/PluginConfiguration.cs index 26d4c3f..e22a636 100644 --- a/Jellyfin.Plugin.Jellypod/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.Jellypod/Configuration/PluginConfiguration.cs @@ -17,6 +17,7 @@ public class PluginConfiguration : BasePluginConfiguration GlobalAutoDownloadEnabled = true; MaxConcurrentDownloads = 2; MaxEpisodesPerPodcast = 50; + MaxEpisodeAgeDays = 0; CreatePodcastFolders = true; DownloadNewEpisodesOnly = true; PostDownloadScriptPath = string.Empty; @@ -49,6 +50,12 @@ public class PluginConfiguration : BasePluginConfiguration /// public int MaxEpisodesPerPodcast { get; set; } + /// + /// Gets or sets the maximum age in days for downloaded episodes (0 = unlimited). + /// Episodes older than this will be automatically deleted. + /// + public int MaxEpisodeAgeDays { get; set; } + /// /// Gets or sets a value indicating whether to create subfolders for each podcast. /// diff --git a/Jellyfin.Plugin.Jellypod/Configuration/configPage.html b/Jellyfin.Plugin.Jellypod/Configuration/configPage.html index acee538..38d000f 100644 --- a/Jellyfin.Plugin.Jellypod/Configuration/configPage.html +++ b/Jellyfin.Plugin.Jellypod/Configuration/configPage.html @@ -164,6 +164,15 @@ Maximum episodes to keep downloaded per podcast (0 = unlimited) +
+ + +
+ Automatically delete episodes downloaded more than this many days ago (0 = unlimited) +
+
@@ -251,6 +260,7 @@ document.querySelector('#GlobalAutoDownloadEnabled').checked = config.GlobalAutoDownloadEnabled; document.querySelector('#MaxConcurrentDownloads').value = config.MaxConcurrentDownloads; document.querySelector('#MaxEpisodesPerPodcast').value = config.MaxEpisodesPerPodcast; + document.querySelector('#MaxEpisodeAgeDays').value = config.MaxEpisodeAgeDays; document.querySelector('#CreatePodcastFolders').checked = config.CreatePodcastFolders; document.querySelector('#PostDownloadScriptPath').value = config.PostDownloadScriptPath || ''; document.querySelector('#PostDownloadScriptTimeout').value = config.PostDownloadScriptTimeout || 60; @@ -432,6 +442,7 @@ config.GlobalAutoDownloadEnabled = document.querySelector('#GlobalAutoDownloadEnabled').checked; config.MaxConcurrentDownloads = parseInt(document.querySelector('#MaxConcurrentDownloads').value, 10); config.MaxEpisodesPerPodcast = parseInt(document.querySelector('#MaxEpisodesPerPodcast').value, 10); + config.MaxEpisodeAgeDays = parseInt(document.querySelector('#MaxEpisodeAgeDays').value, 10); config.CreatePodcastFolders = document.querySelector('#CreatePodcastFolders').checked; config.PostDownloadScriptPath = document.querySelector('#PostDownloadScriptPath').value; config.PostDownloadScriptTimeout = parseInt(document.querySelector('#PostDownloadScriptTimeout').value, 10); diff --git a/Jellyfin.Plugin.Jellypod/Models/Podcast.cs b/Jellyfin.Plugin.Jellypod/Models/Podcast.cs index 41652c8..a670c84 100644 --- a/Jellyfin.Plugin.Jellypod/Models/Podcast.cs +++ b/Jellyfin.Plugin.Jellypod/Models/Podcast.cs @@ -69,6 +69,12 @@ public class Podcast /// public int MaxEpisodesToKeep { get; set; } + /// + /// Gets or sets the maximum age in days for downloaded episodes. + /// 0 = use global setting, -1 = unlimited, greater than 0 = specific days. + /// + public int MaxEpisodeAgeDays { get; set; } + /// /// Gets or sets the list of episodes. /// diff --git a/Jellyfin.Plugin.Jellypod/ScheduledTasks/PodcastUpdateTask.cs b/Jellyfin.Plugin.Jellypod/ScheduledTasks/PodcastUpdateTask.cs index 409f454..948dcc6 100644 --- a/Jellyfin.Plugin.Jellypod/ScheduledTasks/PodcastUpdateTask.cs +++ b/Jellyfin.Plugin.Jellypod/ScheduledTasks/PodcastUpdateTask.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Plugin.Jellypod.Models; using Jellyfin.Plugin.Jellypod.Services; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -117,7 +118,22 @@ public class PodcastUpdateTask : IScheduledTask } processedCount++; - progress.Report((double)processedCount / totalPodcasts * 100); + progress.Report((double)processedCount / totalPodcasts * 90 / 100); + } + + // Run cleanup for expired episodes + _logger.LogInformation("Starting episode retention cleanup"); + foreach (var podcast in podcasts) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await CleanupExpiredEpisodesAsync(podcast, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cleanup expired episodes for: {Title}", podcast.Title); + } } progress.Report(100); @@ -139,4 +155,78 @@ public class PodcastUpdateTask : IScheduledTask } }; } + + /// + /// Cleans up episodes that exceed the retention policy. + /// + /// The podcast to clean up. + /// Cancellation token. + /// A task representing the asynchronous operation. + private async Task CleanupExpiredEpisodesAsync(Podcast podcast, CancellationToken cancellationToken) + { + var config = Plugin.Instance?.Configuration; + + // Determine effective max age for this podcast + int effectiveMaxAgeDays; + if (podcast.MaxEpisodeAgeDays == -1) + { + // Per-podcast override: unlimited + return; + } + else if (podcast.MaxEpisodeAgeDays > 0) + { + // Per-podcast specific value + effectiveMaxAgeDays = podcast.MaxEpisodeAgeDays; + } + else + { + // Use global setting (podcast.MaxEpisodeAgeDays == 0) + effectiveMaxAgeDays = config?.MaxEpisodeAgeDays ?? 0; + } + + // 0 means unlimited + if (effectiveMaxAgeDays <= 0) + { + return; + } + + var cutoffDate = DateTime.UtcNow.AddDays(-effectiveMaxAgeDays); + var expiredEpisodes = podcast.Episodes + .Where(e => e.Status == EpisodeStatus.Downloaded + && e.DownloadedDate.HasValue + && e.DownloadedDate.Value < cutoffDate) + .ToList(); + + if (expiredEpisodes.Count == 0) + { + return; + } + + _logger.LogInformation( + "Found {Count} expired episodes for {Podcast} (older than {Days} days)", + expiredEpisodes.Count, + podcast.Title, + effectiveMaxAgeDays); + + foreach (var episode in expiredEpisodes) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await _downloadService.DeleteEpisodeFileAsync(episode).ConfigureAwait(false); + _logger.LogDebug( + "Deleted expired episode: {Title} (downloaded {Date})", + episode.Title, + episode.DownloadedDate); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete expired episode: {Title}", episode.Title); + } + } + + // Save changes to storage + await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false); + } }