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>
This commit is contained in:
commit
d431486095
38 files changed
+4396
No files matched your search
@@ -0,0 +1,136 @@
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user