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>
237 lines
8.2 KiB
C#
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);
|
|
}
|
|
}
|