Compare commits

...

4 Commits

Author SHA1 Message Date
Gitea Actions
5a908cbe4d Update manifest.json for version 1.0.2 2025-12-30 15:25:33 +00:00
d890c11a9b Add a retention timer for podcast
All checks were successful
🏗️ Build Plugin / build (push) Successful in 2m1s
🧪 Test Plugin / test (push) Successful in 1m0s
🚀 Release Plugin / build-and-release (push) Successful in 2m1s
2025-12-30 16:20:26 +01:00
c54221fba2 Fix bug where stale cache causes media player to mix up episode names
All checks were successful
🏗️ Build Plugin / build (push) Successful in 2m2s
🧪 Test Plugin / test (push) Successful in 58s
2025-12-30 15:53:31 +01:00
Gitea Actions
221a3f634d Update manifest.json for version 1.0.1 2025-12-21 13:07:43 +00:00
10 changed files with 171 additions and 5 deletions

View File

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

View File

@ -14,4 +14,9 @@ public class UpdatePodcastRequest
/// Gets or sets the maximum episodes to keep.
/// </summary>
public int? MaxEpisodesToKeep { get; set; }
/// <summary>
/// Gets or sets the maximum age in days for episodes (0 = use global, -1 = unlimited).
/// </summary>
public int? MaxEpisodeAgeDays { get; set; }
}

View File

@ -387,10 +387,9 @@ public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallbac
/// <inheritdoc />
public string? GetCacheKey(string? userId)
{
// Use 5-minute time buckets for cache key
var now = DateTime.Now;
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 5) * 5, 0);
return timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
// Include database modification time so cache invalidates when podcasts/episodes change
var lastModified = _storageService.LastModified;
return lastModified.ToString("O", CultureInfo.InvariantCulture);
}
/// <inheritdoc />

View File

@ -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
/// </summary>
public int MaxEpisodesPerPodcast { get; set; }
/// <summary>
/// Gets or sets the maximum age in days for downloaded episodes (0 = unlimited).
/// Episodes older than this will be automatically deleted.
/// </summary>
public int MaxEpisodeAgeDays { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to create subfolders for each podcast.
/// </summary>

View File

@ -164,6 +164,15 @@
Maximum episodes to keep downloaded per podcast (0 = unlimited)
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="MaxEpisodeAgeDays">
Max Episode Age (days)
</label>
<input id="MaxEpisodeAgeDays" name="MaxEpisodeAgeDays" type="number" is="emby-input" min="0" />
<div class="fieldDescription">
Automatically delete episodes downloaded more than this many days ago (0 = unlimited)
</div>
</div>
</div>
<!-- Post-Download Processing -->
@ -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);

View File

@ -69,6 +69,12 @@ public class Podcast
/// </summary>
public int MaxEpisodesToKeep { get; set; }
/// <summary>
/// Gets or sets the maximum age in days for downloaded episodes.
/// 0 = use global setting, -1 = unlimited, greater than 0 = specific days.
/// </summary>
public int MaxEpisodeAgeDays { get; set; }
/// <summary>
/// Gets or sets the list of episodes.
/// </summary>

View File

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

View File

@ -10,6 +10,12 @@ namespace Jellyfin.Plugin.Jellypod.Services;
/// </summary>
public interface IPodcastStorageService
{
/// <summary>
/// Gets the cached last modification time (synchronous, for cache key generation).
/// Returns default if database hasn't been loaded yet.
/// </summary>
DateTime LastModified { get; }
/// <summary>
/// Gets all subscribed podcasts.
/// </summary>
@ -57,4 +63,10 @@ public interface IPodcastStorageService
/// </summary>
/// <returns>The base path for podcast storage.</returns>
string GetStoragePath();
/// <summary>
/// Gets the last time the database was modified.
/// </summary>
/// <returns>The last modification time, or null if unknown.</returns>
Task<DateTime?> GetLastModifiedAsync();
}

View File

@ -28,6 +28,7 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
private readonly IApplicationPaths _applicationPaths;
private readonly SemaphoreSlim _dbLock = new(1, 1);
private PodcastDatabase? _cache;
private DateTime _lastModified;
/// <summary>
/// Initializes a new instance of the <see cref="PodcastStorageService"/> class.
@ -47,6 +48,9 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
"Jellypod",
"podcasts.json");
/// <inheritdoc />
public DateTime LastModified => _lastModified;
/// <inheritdoc />
public async Task<IReadOnlyList<Podcast>> GetAllPodcastsAsync()
{
@ -180,6 +184,7 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
{
_logger.LogWarning("Database file does not exist at {Path}", DatabasePath);
_cache = new PodcastDatabase();
_lastModified = DateTime.UtcNow;
return _cache;
}
@ -189,6 +194,7 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
var json = await File.ReadAllTextAsync(DatabasePath).ConfigureAwait(false);
_logger.LogInformation("Read {Length} characters from database file", json.Length);
_cache = JsonSerializer.Deserialize<PodcastDatabase>(json, JsonOptions) ?? new PodcastDatabase();
_lastModified = _cache.LastSaved;
_logger.LogInformation("Loaded {Count} podcasts from database", _cache.Podcasts.Count);
return _cache;
}
@ -196,6 +202,7 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
{
_logger.LogError(ex, "Failed to load podcast database, starting fresh");
_cache = new PodcastDatabase();
_lastModified = DateTime.UtcNow;
return _cache;
}
}
@ -211,6 +218,7 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
}
db.LastSaved = DateTime.UtcNow;
_lastModified = db.LastSaved;
var json = JsonSerializer.Serialize(db, JsonOptions);
await File.WriteAllTextAsync(DatabasePath, json).ConfigureAwait(false);
_cache = db;
@ -261,6 +269,13 @@ public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
return ".mp3";
}
/// <inheritdoc />
public async Task<DateTime?> GetLastModifiedAsync()
{
var db = await LoadDatabaseAsync().ConfigureAwait(false);
return db.LastSaved;
}
/// <inheritdoc />
public void Dispose()
{

View File

@ -8,6 +8,22 @@
"category": "General",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/raw/branch/master/Jellyfin.Plugin.Jellypod/Images/channel-icon.jpg",
"versions": [
{
"version": "1.0.2",
"changelog": "Release 1.0.2",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/releases/download/v1.0.2/jellypod_1.0.2.0.zip",
"checksum": "874f6b76c8cf4bac6495fe946224096a",
"timestamp": "2025-12-30T15:25:33Z"
},
{
"version": "1.0.1",
"changelog": "Release 1.0.1",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/releases/download/v1.0.1/jellypod_1.0.1.0.zip",
"checksum": "8b8cddefe4e6b5c7128e1626a424519b",
"timestamp": "2025-12-21T13:07:42Z"
},
{
"version": "1.0.0",
"changelog": "Release 1.0.0",