using System; 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; namespace Jellyfin.Plugin.Jellypod.ScheduledTasks; /// /// Scheduled task that updates all podcast feeds and downloads new episodes. /// public class PodcastUpdateTask : IScheduledTask { private readonly ILogger _logger; private readonly IRssFeedService _rssFeedService; private readonly IPodcastStorageService _storageService; private readonly IPodcastDownloadService _downloadService; /// /// Initializes a new instance of the class. /// /// Logger instance. /// RSS feed service. /// Storage service. /// Download service. public PodcastUpdateTask( ILogger logger, IRssFeedService rssFeedService, IPodcastStorageService storageService, IPodcastDownloadService downloadService) { _logger = logger; _rssFeedService = rssFeedService; _storageService = storageService; _downloadService = downloadService; } /// public string Name => "Update Podcast Feeds"; /// public string Key => "JellypodUpdateFeeds"; /// public string Description => "Checks all subscribed podcasts for new episodes and downloads them."; /// public string Category => "Jellypod"; /// public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) { _logger.LogInformation("Starting podcast feed update task"); var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false); var totalPodcasts = podcasts.Count; var processedCount = 0; foreach (var podcast in podcasts) { cancellationToken.ThrowIfCancellationRequested(); try { _logger.LogDebug("Updating podcast: {Title}", podcast.Title); var updatedPodcast = await _rssFeedService.FetchPodcastAsync(podcast.FeedUrl, cancellationToken).ConfigureAwait(false); if (updatedPodcast != null) { // Find new episodes (by GUID) var existingGuids = podcast.Episodes .Select(e => e.EpisodeGuid) .Where(g => !string.IsNullOrEmpty(g)) .ToHashSet(StringComparer.Ordinal); var newEpisodes = updatedPodcast.Episodes .Where(e => !string.IsNullOrEmpty(e.EpisodeGuid) && !existingGuids.Contains(e.EpisodeGuid)) .ToList(); if (newEpisodes.Count > 0) { _logger.LogInformation("Found {Count} new episodes for {Title}", newEpisodes.Count, podcast.Title); // Add new episodes to podcast foreach (var episode in newEpisodes) { episode.PodcastId = podcast.Id; podcast.Episodes.Insert(0, episode); } podcast.LastUpdated = DateTime.UtcNow; // Auto-download if enabled var config = Plugin.Instance?.Configuration; if (config?.GlobalAutoDownloadEnabled == true && podcast.AutoDownloadEnabled) { foreach (var episode in newEpisodes) { await _downloadService.QueueDownloadAsync(podcast, episode).ConfigureAwait(false); } } await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false); } else { _logger.LogDebug("No new episodes for {Title}", podcast.Title); } } } catch (Exception ex) { _logger.LogError(ex, "Failed to update podcast: {Title}", podcast.Title); } processedCount++; 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); _logger.LogInformation("Podcast feed update task completed"); } /// public IEnumerable GetDefaultTriggers() { var config = Plugin.Instance?.Configuration; var intervalHours = config?.UpdateIntervalHours ?? 6; return new[] { new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks } }; } /// /// 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); } }