Files
jellypod/Jellyfin.Plugin.Jellypod/ScheduledTasks/PodcastUpdateTask.cs
T
dtourolleandClaude Opus 5 c67005bd8f
🏗️ Build Plugin / build (push) Successful in 2m9s
🚀 Release Plugin / build-and-release (push) Successful in 1m33s
Add Jellyfin 12.0 build target alongside 10.9
The plugin now multi-targets net8.0 (Jellyfin 10.9, ABI 10.9.0.0) and net10.0
(Jellyfin 12.0, ABI 12.0.0.0) from one source tree, and each release ships both
packages.

TaskTriggerInfo.Type became a TaskTriggerInfoType enum in 12.0, which is the
only source-level break; it is handled with a NET10_0_OR_GREATER switch. CA1873
is disabled in the ruleset alongside CA1848, which it supersedes.

build.yaml stays the 10.9 config and the 12.0 one is derived from it at build
time, so the two cannot drift apart. Both manifest entries are prepended per
release with the 12.0 one first: Jellyfin keeps entries whose targetAbi is <=
the server version and takes the first of the highest version, so a 12.0 server
picks the 12.0 build while a 10.9 server never sees it.

The builder image now carries the .NET 10 SDK with the .NET 8 SDK alongside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:51:45 +02:00

237 lines
8.2 KiB
C#

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;
/// <summary>
/// Scheduled task that updates all podcast feeds and downloads new episodes.
/// </summary>
public class PodcastUpdateTask : IScheduledTask
{
private readonly ILogger<PodcastUpdateTask> _logger;
private readonly IRssFeedService _rssFeedService;
private readonly IPodcastStorageService _storageService;
private readonly IPodcastDownloadService _downloadService;
/// <summary>
/// Initializes a new instance of the <see cref="PodcastUpdateTask"/> class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="rssFeedService">RSS feed service.</param>
/// <param name="storageService">Storage service.</param>
/// <param name="downloadService">Download service.</param>
public PodcastUpdateTask(
ILogger<PodcastUpdateTask> logger,
IRssFeedService rssFeedService,
IPodcastStorageService storageService,
IPodcastDownloadService downloadService)
{
_logger = logger;
_rssFeedService = rssFeedService;
_storageService = storageService;
_downloadService = downloadService;
}
/// <inheritdoc />
public string Name => "Update Podcast Feeds";
/// <inheritdoc />
public string Key => "JellypodUpdateFeeds";
/// <inheritdoc />
public string Description => "Checks all subscribed podcasts for new episodes and downloads them.";
/// <inheritdoc />
public string Category => "Jellypod";
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> 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");
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
var config = Plugin.Instance?.Configuration;
var intervalHours = config?.UpdateIntervalHours ?? 6;
return new[]
{
new TaskTriggerInfo
{
#if NET10_0_OR_GREATER
Type = TaskTriggerInfoType.IntervalTrigger,
#else
Type = TaskTriggerInfo.TriggerInterval,
#endif
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
}
};
}
/// <summary>
/// Cleans up episodes that exceed the retention policy.
/// </summary>
/// <param name="podcast">The podcast to clean up.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
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);
}
}