Files
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

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