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)
+
@@ -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);
+ }
}