feat: Recently Added Guard plugin for Jellyfin 12.2
build / build (push) Successful in 1m10s
release / release (push) Failing after 1m36s

Keeps Radarr/Sonarr quality upgrades out of "Recently Added" by keeping
a ledger of the first date added per logical medium (movie or episode,
identified by provider ids and the series) and restoring it on upgrades.

- core logic without Jellyfin types (Reconciler, MediaKeys, GuardProcessor)
- thread-safe ledger with atomic, debounced persistence
- event processing via channel and background worker, seed on first start
- scheduled task "Ledger synchronisieren", status API, configuration page
- xUnit tests and a Jellyfin 12.2 integration test
- Gitea workflows: build/test, release with jprm and manifest branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-21 23:10:02 +02:00
commit d431486095
38 files changed
+4396

No files matched your search

@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// Maps Jellyfin items to <see cref="MediaIdentity"/> snapshots for the core logic.
/// </summary>
public sealed class IdentityResolver
{
private readonly ILibraryManager _libraryManager;
private readonly ILogger<IdentityResolver> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="IdentityResolver"/> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="logger">The logger.</param>
public IdentityResolver(ILibraryManager libraryManager, ILogger<IdentityResolver> logger)
{
_libraryManager = libraryManager;
_logger = logger;
}
/// <summary>
/// Checks whether the item is a real movie or episode: no missing (virtual) episode, no extra.
/// </summary>
/// <param name="item">The item.</param>
/// <returns><c>true</c> if the plugin tracks items of this kind.</returns>
public static bool IsTracked(BaseItem item)
=> item is Movie or Episode && !item.IsVirtualItem && item.ExtraType is null;
/// <summary>
/// Resolves the identity of a tracked item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="seriesLookup">Optional cached series lookup (used by the synchronization).</param>
/// <returns>The identity (its keys may be empty if provider ids are still missing),
/// or <c>null</c> if the item is not tracked or its kind is disabled.</returns>
public MediaIdentity? Resolve(BaseItem item, Func<Guid, Series?>? seriesLookup = null)
{
ArgumentNullException.ThrowIfNull(item);
if (!IsTracked(item))
{
return null;
}
var configuration = Plugin.CurrentConfiguration;
switch (item)
{
case Movie movie when configuration.EnableMovies:
return new MediaIdentity(movie.Id, MediaKind.Movie, movie.DateCreated, MovieKeys(movie), MovieName(movie));
case Episode episode when configuration.EnableEpisodes:
var seriesId = FindSeriesId(episode);
var series = FindSeries(seriesId, seriesLookup);
return new MediaIdentity(
episode.Id,
MediaKind.Episode,
episode.DateCreated,
EpisodeKeys(episode, seriesId, series),
EpisodeName(episode, series));
default:
return null;
}
}
/// <summary>
/// Resolves the keys of a removed item. The provider ids are still attached to the object,
/// but the series may already be gone, so every lookup is defensive.
/// </summary>
/// <param name="item">The removed item.</param>
/// <returns>The keys; empty if they cannot be determined.</returns>
public IReadOnlyList<string> ResolveKeysForRemoval(BaseItem item)
{
ArgumentNullException.ThrowIfNull(item);
switch (item)
{
case Movie movie:
return MovieKeys(movie);
case Episode episode:
var seriesId = FindSeriesId(episode);
return EpisodeKeys(episode, seriesId, FindSeries(seriesId, null));
default:
return [];
}
}
private static IReadOnlyList<string> MovieKeys(Movie movie)
=> MediaKeys.ForMovie(
movie.GetProviderId(MetadataProvider.Tmdb),
movie.GetProviderId(MetadataProvider.Imdb),
movie.GetProviderId(MetadataProvider.Tvdb));
private static IReadOnlyList<string> EpisodeKeys(Episode episode, Guid seriesId, Series? series)
{
var part = MediaKeys.EpisodePart(episode.ParentIndexNumber, episode.IndexNumber, episode.IndexNumberEnd);
if (part is null)
{
return [];
}
return MediaKeys.ForEpisode(
part,
series?.GetProviderId(MetadataProvider.Tvdb),
series?.GetProviderId(MetadataProvider.Tmdb),
series?.GetProviderId(MetadataProvider.Imdb),
seriesId);
}
private static string MovieName(Movie movie)
{
var name = string.IsNullOrWhiteSpace(movie.Name) ? movie.Id.ToString("N", CultureInfo.InvariantCulture) : movie.Name;
return movie.ProductionYear is { } year
? string.Create(CultureInfo.InvariantCulture, $"{name} ({year})")
: name;
}
private static string EpisodeName(Episode episode, Series? series)
{
var seriesName = episode.SeriesName ?? series?.Name ?? "?";
var number = string.Create(CultureInfo.InvariantCulture, $"S{episode.ParentIndexNumber ?? 0:00}E{episode.IndexNumber ?? 0:00}");
if (episode.IndexNumberEnd is { } end && end > (episode.IndexNumber ?? 0))
{
number += string.Create(CultureInfo.InvariantCulture, $"-E{end:00}");
}
return string.IsNullOrWhiteSpace(episode.Name)
? $"{seriesName} {number}"
: $"{seriesName} {number} – {episode.Name}";
}
#pragma warning disable CA1031 // Lookups on half-deleted items may fail in many ways; a missing key is acceptable.
private Guid FindSeriesId(Episode episode)
{
if (episode.SeriesId != Guid.Empty)
{
return episode.SeriesId;
}
try
{
return episode.FindSeriesId();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Serie von Episode {ItemId} nicht ermittelbar", episode.Id);
return Guid.Empty;
}
}
private Series? FindSeries(Guid seriesId, Func<Guid, Series?>? seriesLookup)
{
if (seriesId == Guid.Empty)
{
return null;
}
try
{
return seriesLookup is not null ? seriesLookup(seriesId) : _libraryManager.GetItemById(seriesId) as Series;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Serie {SeriesId} nicht auffindbar", seriesId);
return null;
}
}
#pragma warning restore CA1031
}