Files
jellyfin-plugin-recently-ad…/Jellyfin.Plugin.RecentlyAddedGuard/Core/IdentityResolver.cs
T
Jonas MenzelandClaude Opus 5.5 d431486095
build / build (push) Successful in 1m10s
release / release (push) Failing after 1m36s
feat: Recently Added Guard plugin for Jellyfin 12.2
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>
2026-10-21 23:10:02 +02:00

179 lines
6.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}