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>
179 lines
6.3 KiB
C#
179 lines
6.3 KiB
C#
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
|
||
}
|