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>
137 lines
3.9 KiB
C#
137 lines
3.9 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
|
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Xunit;
|
|
|
|
namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests;
|
|
|
|
internal sealed class ManualTimeProvider : TimeProvider
|
|
{
|
|
public ManualTimeProvider(DateTimeOffset start)
|
|
{
|
|
Now = start;
|
|
}
|
|
|
|
public DateTimeOffset Now { get; set; }
|
|
|
|
public override DateTimeOffset GetUtcNow() => Now;
|
|
|
|
public void Advance(TimeSpan by) => Now += by;
|
|
}
|
|
|
|
internal sealed class RecordingDateWriter : IItemDateWriter
|
|
{
|
|
public List<(Guid ItemId, DateTime Date)> Writes { get; } = [];
|
|
|
|
public bool ItemExists { get; set; } = true;
|
|
|
|
public Task<bool> TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken)
|
|
{
|
|
if (!ItemExists)
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
Writes.Add((itemId, dateCreated));
|
|
return Task.FromResult(true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wires the core classes against a temporary ledger file and a manual clock.
|
|
/// </summary>
|
|
internal sealed class GuardFixture : IDisposable
|
|
{
|
|
public static readonly DateTimeOffset Start = new(2026, 3, 1, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
public GuardFixture(GuardOptions? options = null)
|
|
{
|
|
DirectoryPath = Path.Combine(Path.GetTempPath(), "rag-tests", Guid.NewGuid().ToString("N"));
|
|
Time = new ManualTimeProvider(Start);
|
|
Options = options ?? GuardOptions.Default;
|
|
Store = CreateStore();
|
|
Reconciler = new Reconciler(Store, () => Options, Time, NullLogger<Reconciler>.Instance);
|
|
Writer = new RecordingDateWriter();
|
|
Corrections = new CorrectionLog();
|
|
Processor = new GuardProcessor(Reconciler, Writer, Corrections, () => Options, Time, NullLogger<GuardProcessor>.Instance);
|
|
}
|
|
|
|
public string DirectoryPath { get; }
|
|
|
|
public string LedgerPath => Path.Combine(DirectoryPath, LedgerStore.DefaultFileName);
|
|
|
|
public ManualTimeProvider Time { get; }
|
|
|
|
public GuardOptions Options { get; set; }
|
|
|
|
public LedgerStore Store { get; }
|
|
|
|
public Reconciler Reconciler { get; }
|
|
|
|
public RecordingDateWriter Writer { get; }
|
|
|
|
public CorrectionLog Corrections { get; }
|
|
|
|
public GuardProcessor Processor { get; }
|
|
|
|
public DateTime Now => Time.GetUtcNow().UtcDateTime;
|
|
|
|
/// <summary>
|
|
/// Creates another store on the same file. The save delay is long, so tests control writes via Flush.
|
|
/// </summary>
|
|
public LedgerStore CreateStore(TimeSpan? saveDelay = null)
|
|
=> new(LedgerPath, NullLogger<LedgerStore>.Instance, Time, saveDelay ?? TimeSpan.FromHours(1));
|
|
|
|
public LedgerEntry SingleEntry()
|
|
{
|
|
var entries = Store.GetEntries();
|
|
Assert.Single(entries);
|
|
return entries[0];
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Store.Dispose();
|
|
try
|
|
{
|
|
Directory.Delete(DirectoryPath, recursive: true);
|
|
}
|
|
catch (DirectoryNotFoundException)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static class Media
|
|
{
|
|
public static MediaIdentity Movie(
|
|
Guid itemId,
|
|
DateTime dateCreated,
|
|
string? tmdb = null,
|
|
string? imdb = null,
|
|
string? tvdb = null,
|
|
string name = "The Matrix (1999)")
|
|
=> new(itemId, MediaKind.Movie, dateCreated, MediaKeys.ForMovie(tmdb, imdb, tvdb), name);
|
|
|
|
public static MediaIdentity Episode(
|
|
Guid itemId,
|
|
DateTime dateCreated,
|
|
Guid seriesId,
|
|
int season,
|
|
int episode,
|
|
int? episodeEnd = null,
|
|
string? seriesTvdb = null,
|
|
string name = "Andor S01E01")
|
|
=> new(
|
|
itemId,
|
|
MediaKind.Episode,
|
|
dateCreated,
|
|
MediaKeys.ForEpisode(MediaKeys.EpisodePart(season, episode, episodeEnd)!, seriesTvdb, null, null, seriesId),
|
|
name);
|
|
}
|