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,76 @@
|
||||
using System.Net.Mime;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Tasks;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Status and actions for the configuration page. Jellyfin adds plugin assemblies as MVC application parts,
|
||||
/// so this controller is discovered automatically.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("RecentlyAddedGuard")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public class GuardController : ControllerBase
|
||||
{
|
||||
private readonly LedgerStore _store;
|
||||
private readonly CorrectionLog _corrections;
|
||||
private readonly ITaskManager _taskManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GuardController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="store">The ledger.</param>
|
||||
/// <param name="corrections">The correction log.</param>
|
||||
/// <param name="taskManager">The task manager.</param>
|
||||
public GuardController(LedgerStore store, CorrectionLog corrections, ITaskManager taskManager)
|
||||
{
|
||||
_store = store;
|
||||
_corrections = corrections;
|
||||
_taskManager = taskManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets ledger statistics and the most recent corrections.
|
||||
/// </summary>
|
||||
/// <response code="200">The status.</response>
|
||||
/// <returns>The status.</returns>
|
||||
[HttpGet("Status")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<GuardStatus> GetStatus()
|
||||
{
|
||||
var statistics = _store.GetStatistics();
|
||||
return new GuardStatus
|
||||
{
|
||||
LedgerEntries = statistics.Entries,
|
||||
LiveEntries = statistics.LiveEntries,
|
||||
RemovedEntries = statistics.RemovedEntries,
|
||||
CorrectionsSinceStart = _corrections.CorrectionsSinceStart,
|
||||
DryRunDetectionsSinceStart = _corrections.DryRunDetectionsSinceStart,
|
||||
SeedCompleted = _store.SeedCompleted,
|
||||
LastSyncUtc = _store.LastSyncUtc,
|
||||
DryRun = Plugin.CurrentConfiguration.DryRun,
|
||||
RecentCorrections = _corrections.GetRecent()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues the scheduled task "Ledger synchronisieren".
|
||||
/// </summary>
|
||||
/// <response code="204">The task was queued (or is already running).</response>
|
||||
/// <returns>No content.</returns>
|
||||
[HttpPost("Sync")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult StartSync()
|
||||
{
|
||||
_taskManager.QueueIfNotRunning<SyncLedgerTask>();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Status shown on the configuration page.
|
||||
/// </summary>
|
||||
public sealed class GuardStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the number of ledger entries.
|
||||
/// </summary>
|
||||
public int LedgerEntries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of entries with at least one live item.
|
||||
/// </summary>
|
||||
public int LiveEntries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of entries whose items are gone (within the grace period).
|
||||
/// </summary>
|
||||
public int RemovedEntries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of corrections since the server started.
|
||||
/// </summary>
|
||||
public long CorrectionsSinceStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of upgrades detected in dry-run mode since the server started.
|
||||
/// </summary>
|
||||
public long DryRunDetectionsSinceStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the initial seed has completed.
|
||||
/// </summary>
|
||||
public bool SeedCompleted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time of the last seed or synchronization (UTC).
|
||||
/// </summary>
|
||||
public DateTime? LastSyncUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether dry-run mode is active.
|
||||
/// </summary>
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most recent corrections, newest first.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CorrectionRecord> RecentCorrections { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// The plugin configuration.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the number of days within which a returning medium counts as an upgrade.
|
||||
/// </summary>
|
||||
public int GraceDays { get; set; } = 14;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether movies are protected.
|
||||
/// </summary>
|
||||
public bool EnableMovies { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether episodes are protected.
|
||||
/// </summary>
|
||||
public bool EnableEpisodes { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether upgrades are only logged, not corrected.
|
||||
/// </summary>
|
||||
public bool DryRun { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tolerance in seconds for comparing dates.
|
||||
/// </summary>
|
||||
public int ToleranceSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the configuration to the options of the core logic.
|
||||
/// </summary>
|
||||
/// <returns>The options.</returns>
|
||||
public GuardOptions ToGuardOptions() => new(GraceDays, ToleranceSeconds, DryRun);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Recently Added Guard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="RecentlyAddedGuardConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-checkbox">
|
||||
<style>
|
||||
#RecentlyAddedGuardConfigPage .ragStats { display: grid; grid-template-columns: repeat(auto-fit, minmax(11em, 1fr)); gap: 0.75em; margin: 1em 0; }
|
||||
#RecentlyAddedGuardConfigPage .ragStat { padding: 0.75em 1em; border-radius: 0.3em; background: rgba(128, 128, 128, 0.12); }
|
||||
#RecentlyAddedGuardConfigPage .ragStatValue { font-size: 1.6em; font-weight: 600; }
|
||||
#RecentlyAddedGuardConfigPage .ragStatLabel { opacity: 0.75; font-size: 0.9em; }
|
||||
#RecentlyAddedGuardConfigPage .ragTableWrap { overflow-x: auto; }
|
||||
#RecentlyAddedGuardConfigPage table { width: 100%; border-collapse: collapse; }
|
||||
#RecentlyAddedGuardConfigPage th, #RecentlyAddedGuardConfigPage td { text-align: left; padding: 0.45em 0.6em; border-bottom: 1px solid rgba(128, 128, 128, 0.25); white-space: nowrap; }
|
||||
#RecentlyAddedGuardConfigPage td.ragName { white-space: normal; }
|
||||
#RecentlyAddedGuardConfigPage .ragHint { opacity: 0.75; }
|
||||
</style>
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<h2 class="sectionTitle">Recently Added Guard</h2>
|
||||
<p class="ragHint">
|
||||
Bewahrt das erste Hinzufügedatum jedes Films und jeder Episode. Qualitäts-Upgrades aus Radarr und Sonarr
|
||||
behalten so ihr ursprüngliches Datum und erscheinen nicht erneut unter „Zuletzt hinzugefügt“.
|
||||
</p>
|
||||
|
||||
<form id="RecentlyAddedGuardConfigForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RagGraceDays">Frist in Tagen</label>
|
||||
<input id="RagGraceDays" name="GraceDays" type="number" is="emby-input" min="0" max="3650" step="1" required />
|
||||
<div class="fieldDescription">
|
||||
Taucht ein gelöschter Film oder eine gelöschte Episode innerhalb dieser Frist wieder auf, gilt das als Upgrade und
|
||||
das alte Datum wird wiederhergestellt. Nach Ablauf gilt das Medium als neu. Standard: 14.
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RagToleranceSeconds">Toleranz in Sekunden</label>
|
||||
<input id="RagToleranceSeconds" name="ToleranceSeconds" type="number" is="emby-input" min="0" max="86400" step="1" required />
|
||||
<div class="fieldDescription">Kleinere Abweichungen des Datums werden ignoriert. Standard: 60.</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="RagEnableMovies" name="EnableMovies" type="checkbox" is="emby-checkbox" />
|
||||
<span>Filme schützen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="RagEnableEpisodes" name="EnableEpisodes" type="checkbox" is="emby-checkbox" />
|
||||
<span>Episoden schützen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="RagDryRun" name="DryRun" type="checkbox" is="emby-checkbox" />
|
||||
<span>Testmodus (DryRun)</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">Upgrades nur protokollieren, kein Datum ändern.</div>
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>Speichern</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="verticalSection" style="margin-top: 2.5em;">
|
||||
<h2 class="sectionTitle">Status</h2>
|
||||
<div class="ragStats">
|
||||
<div class="ragStat"><div id="RagLedgerEntries" class="ragStatValue">–</div><div class="ragStatLabel">Ledger-Einträge</div></div>
|
||||
<div class="ragStat"><div id="RagLiveEntries" class="ragStatValue">–</div><div class="ragStatLabel">davon mit Items</div></div>
|
||||
<div class="ragStat"><div id="RagRemovedEntries" class="ragStatValue">–</div><div class="ragStatLabel">in der Frist</div></div>
|
||||
<div class="ragStat"><div id="RagCorrections" class="ragStatValue">–</div><div class="ragStatLabel">Korrekturen seit Start</div></div>
|
||||
</div>
|
||||
<p id="RagSyncInfo" class="ragHint"></p>
|
||||
<button is="emby-button" type="button" id="RagSyncButton" class="raised block emby-button">
|
||||
<span>Ledger synchronisieren</span>
|
||||
</button>
|
||||
|
||||
<h3 style="margin-top: 2em;">Letzte Korrekturen</h3>
|
||||
<div class="ragTableWrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Altes Datum</th>
|
||||
<th>Neues Datum</th>
|
||||
<th>Zeitpunkt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="RagCorrectionRows">
|
||||
<tr><td colspan="4" class="ragHint">Lädt …</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
var pluginId = '6fdfedef-adde-4ac3-a873-62a057c95574';
|
||||
var page = document.querySelector('#RecentlyAddedGuardConfigPage');
|
||||
var form = page.querySelector('#RecentlyAddedGuardConfigForm');
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return '–';
|
||||
}
|
||||
|
||||
var date = new Date(value);
|
||||
return isNaN(date.getTime()) ? escapeHtml(value) : date.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
function loadConfiguration() {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
||||
page.querySelector('#RagGraceDays').value = config.GraceDays;
|
||||
page.querySelector('#RagToleranceSeconds').value = config.ToleranceSeconds;
|
||||
page.querySelector('#RagEnableMovies').checked = config.EnableMovies;
|
||||
page.querySelector('#RagEnableEpisodes').checked = config.EnableEpisodes;
|
||||
page.querySelector('#RagDryRun').checked = config.DryRun;
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
page.querySelector('#RagLedgerEntries').textContent = status.LedgerEntries;
|
||||
page.querySelector('#RagLiveEntries').textContent = status.LiveEntries;
|
||||
page.querySelector('#RagRemovedEntries').textContent = status.RemovedEntries;
|
||||
page.querySelector('#RagCorrections').textContent = status.DryRun
|
||||
? status.CorrectionsSinceStart + ' (+' + status.DryRunDetectionsSinceStart + ' DryRun)'
|
||||
: status.CorrectionsSinceStart;
|
||||
|
||||
var info = status.SeedCompleted
|
||||
? 'Letzte Synchronisation: ' + formatDate(status.LastSyncUtc) + '.'
|
||||
: 'Das Ledger wird gerade zum ersten Mal befüllt (Seed).';
|
||||
if (status.DryRun) {
|
||||
info += ' Testmodus aktiv: Upgrades werden nur protokolliert.';
|
||||
}
|
||||
page.querySelector('#RagSyncInfo').textContent = info;
|
||||
|
||||
var rows = (status.RecentCorrections || []).map(function (c) {
|
||||
return '<tr>'
|
||||
+ '<td class="ragName">' + escapeHtml(c.Name) + (c.DryRun ? ' <span class="ragHint">(DryRun)</span>' : '') + '</td>'
|
||||
+ '<td>' + formatDate(c.PreviousDate) + '</td>'
|
||||
+ '<td>' + formatDate(c.RestoredDate) + '</td>'
|
||||
+ '<td>' + formatDate(c.CorrectedAt) + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
page.querySelector('#RagCorrectionRows').innerHTML = rows.length
|
||||
? rows.join('')
|
||||
: '<tr><td colspan="4" class="ragHint">Seit dem Start wurden keine Upgrades korrigiert.</td></tr>';
|
||||
}
|
||||
|
||||
function loadStatus() {
|
||||
return ApiClient.getJSON(ApiClient.getUrl('RecentlyAddedGuard/Status')).then(renderStatus, function () {
|
||||
page.querySelector('#RagSyncInfo').textContent = 'Status konnte nicht geladen werden.';
|
||||
});
|
||||
}
|
||||
|
||||
page.addEventListener('pageshow', function () {
|
||||
loadConfiguration();
|
||||
loadStatus();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
||||
config.GraceDays = parseInt(page.querySelector('#RagGraceDays').value, 10) || 0;
|
||||
config.ToleranceSeconds = parseInt(page.querySelector('#RagToleranceSeconds').value, 10) || 0;
|
||||
config.EnableMovies = page.querySelector('#RagEnableMovies').checked;
|
||||
config.EnableEpisodes = page.querySelector('#RagEnableEpisodes').checked;
|
||||
config.DryRun = page.querySelector('#RagDryRun').checked;
|
||||
ApiClient.updatePluginConfiguration(pluginId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
loadStatus();
|
||||
});
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
page.querySelector('#RagSyncButton').addEventListener('click', function () {
|
||||
var button = this;
|
||||
button.disabled = true;
|
||||
ApiClient.ajax({ type: 'POST', url: ApiClient.getUrl('RecentlyAddedGuard/Sync') }).then(function () {
|
||||
page.querySelector('#RagSyncInfo').textContent = 'Synchronisation gestartet. Fortschritt unter Geplante Aufgaben.';
|
||||
setTimeout(loadStatus, 5000);
|
||||
}, function () {
|
||||
page.querySelector('#RagSyncInfo').textContent = 'Synchronisation konnte nicht gestartet werden.';
|
||||
}).then(function () {
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// One restored (or, in dry-run mode, detected) upgrade.
|
||||
/// </summary>
|
||||
/// <param name="ItemId">The corrected item.</param>
|
||||
/// <param name="Name">The display name.</param>
|
||||
/// <param name="PreviousDate">The "date added" the upgrade brought along (UTC).</param>
|
||||
/// <param name="RestoredDate">The first "date added" from the ledger (UTC).</param>
|
||||
/// <param name="CorrectedAt">When the correction happened (UTC).</param>
|
||||
/// <param name="DryRun"><c>true</c> if only detected, not applied.</param>
|
||||
public sealed record CorrectionRecord(
|
||||
Guid ItemId,
|
||||
string Name,
|
||||
DateTime PreviousDate,
|
||||
DateTime RestoredDate,
|
||||
DateTime CorrectedAt,
|
||||
bool DryRun);
|
||||
|
||||
/// <summary>
|
||||
/// In-memory ring buffer of the most recent corrections, for the configuration page.
|
||||
/// </summary>
|
||||
public sealed class CorrectionLog
|
||||
{
|
||||
/// <summary>
|
||||
/// The default number of kept corrections.
|
||||
/// </summary>
|
||||
public const int DefaultCapacity = 100;
|
||||
|
||||
private readonly Lock _sync = new();
|
||||
private readonly List<CorrectionRecord> _records;
|
||||
private readonly int _capacity;
|
||||
private long _corrections;
|
||||
private long _dryRunDetections;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CorrectionLog"/> class.
|
||||
/// </summary>
|
||||
/// <param name="capacity">The number of kept corrections.</param>
|
||||
public CorrectionLog(int capacity = DefaultCapacity)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
|
||||
_capacity = capacity;
|
||||
_records = new List<CorrectionRecord>(capacity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of applied corrections since the server started.
|
||||
/// </summary>
|
||||
public long CorrectionsSinceStart => Interlocked.Read(ref _corrections);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of upgrades detected in dry-run mode since the server started.
|
||||
/// </summary>
|
||||
public long DryRunDetectionsSinceStart => Interlocked.Read(ref _dryRunDetections);
|
||||
|
||||
/// <summary>
|
||||
/// Records a correction, dropping the oldest one when full. Restoring the same date of the same item again
|
||||
/// (Jellyfin saved the item with its new date once more, e.g. from a metadata refresh that was already running)
|
||||
/// replaces the earlier record and is not counted twice.
|
||||
/// </summary>
|
||||
/// <param name="record">The correction.</param>
|
||||
/// <returns><c>true</c> for a new correction, <c>false</c> for a repeated one.</returns>
|
||||
public bool Add(CorrectionRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
var existing = _records.FindIndex(r => r.ItemId == record.ItemId
|
||||
&& r.RestoredDate == record.RestoredDate
|
||||
&& r.DryRun == record.DryRun);
|
||||
if (existing >= 0)
|
||||
{
|
||||
_records.RemoveAt(existing);
|
||||
_records.Add(record);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_records.Count == _capacity)
|
||||
{
|
||||
_records.RemoveAt(0);
|
||||
}
|
||||
|
||||
_records.Add(record);
|
||||
}
|
||||
|
||||
if (record.DryRun)
|
||||
{
|
||||
Interlocked.Increment(ref _dryRunDetections);
|
||||
}
|
||||
else
|
||||
{
|
||||
Interlocked.Increment(ref _corrections);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the kept corrections, newest first.
|
||||
/// </summary>
|
||||
/// <returns>The corrections.</returns>
|
||||
public IReadOnlyList<CorrectionRecord> GetRecent()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _records.AsEnumerable().Reverse().ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The settings the core logic depends on, decoupled from the Jellyfin plugin configuration.
|
||||
/// </summary>
|
||||
/// <param name="GraceDays">Days within which a returning medium counts as an upgrade.</param>
|
||||
/// <param name="ToleranceSeconds">Date differences up to this many seconds are ignored.</param>
|
||||
/// <param name="DryRun">Only log detected upgrades, never change items.</param>
|
||||
public sealed record GuardOptions(int GraceDays, int ToleranceSeconds, bool DryRun)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the defaults (14 days, 60 seconds, no dry run).
|
||||
/// </summary>
|
||||
public static GuardOptions Default { get; } = new(14, 60, false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the grace period.
|
||||
/// </summary>
|
||||
public TimeSpan GracePeriod => TimeSpan.FromDays(Math.Max(0, GraceDays));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tolerance for date comparisons.
|
||||
/// </summary>
|
||||
public TimeSpan Tolerance => TimeSpan.FromSeconds(Math.Max(0, ToleranceSeconds));
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Turns reconciler decisions into actions: restores the date of upgrades (or only logs them in dry-run mode)
|
||||
/// and records corrections.
|
||||
/// </summary>
|
||||
public sealed class GuardProcessor
|
||||
{
|
||||
private readonly Reconciler _reconciler;
|
||||
private readonly IItemDateWriter _writer;
|
||||
private readonly CorrectionLog _corrections;
|
||||
private readonly Func<GuardOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<GuardProcessor> _logger;
|
||||
private readonly ConcurrentDictionary<Guid, DateTime> _dryRunReported = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GuardProcessor"/> class.
|
||||
/// </summary>
|
||||
/// <param name="reconciler">The reconciler.</param>
|
||||
/// <param name="writer">Writes item dates.</param>
|
||||
/// <param name="corrections">The correction log.</param>
|
||||
/// <param name="options">Provides the current options.</param>
|
||||
/// <param name="timeProvider">The clock.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public GuardProcessor(
|
||||
Reconciler reconciler,
|
||||
IItemDateWriter writer,
|
||||
CorrectionLog corrections,
|
||||
Func<GuardOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<GuardProcessor> logger)
|
||||
{
|
||||
_reconciler = reconciler ?? throw new ArgumentNullException(nameof(reconciler));
|
||||
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
|
||||
_corrections = corrections ?? throw new ArgumentNullException(nameof(corrections));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles an added or updated item.
|
||||
/// </summary>
|
||||
/// <param name="identity">The item.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The reconciler's outcome.</returns>
|
||||
public async Task<ReconcileResult> ProcessSeenAsync(MediaIdentity identity, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = _reconciler.OnItemSeen(identity);
|
||||
switch (result.Action)
|
||||
{
|
||||
case ReconcileAction.UpgradeDetected:
|
||||
await RestoreDateAsync(result, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case ReconcileAction.ReturnedAfterGrace:
|
||||
_logger.LogInformation(
|
||||
"{Name} ist nach Ablauf der Frist zurückgekehrt und gilt als neu (DateAdded {Date:u})",
|
||||
identity.DisplayName,
|
||||
result.ItemDate);
|
||||
break;
|
||||
case ReconcileAction.Registered:
|
||||
_logger.LogDebug("Neu registriert: {Name} (DateAdded {Date:u})", identity.DisplayName, result.ItemDate);
|
||||
break;
|
||||
case ReconcileAction.FirstAddedLowered:
|
||||
_logger.LogDebug("Erstes Hinzufügedatum von {Name} auf {Date:u} vorverlegt", identity.DisplayName, result.ItemDate);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a removed item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The removed item.</param>
|
||||
/// <param name="keys">The keys the item still resolves to; may be empty.</param>
|
||||
/// <returns><c>true</c> if a ledger entry was affected.</returns>
|
||||
public bool ProcessRemoved(Guid itemId, IReadOnlyList<string> keys) => _reconciler.OnItemRemoved(itemId, keys);
|
||||
|
||||
private async Task RestoreDateAsync(ReconcileResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
var identity = result.Identity;
|
||||
if (_options().DryRun)
|
||||
{
|
||||
// Every metadata refresh of the item reports the upgrade again; log it once per date.
|
||||
if (_dryRunReported.TryGetValue(identity.ItemId, out var reported) && reported == result.ItemDate)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dryRunReported[identity.ItemId] = result.ItemDate;
|
||||
_logger.LogInformation(
|
||||
"[DryRun] Upgrade erkannt: {Name} – DateAdded {New:u} → {Original:u} (nicht geändert)",
|
||||
identity.DisplayName,
|
||||
result.ItemDate,
|
||||
result.FirstAdded);
|
||||
_corrections.Add(new CorrectionRecord(identity.ItemId, identity.DisplayName, result.ItemDate, result.FirstAdded, UtcNow(), DryRun: true));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await _writer.TrySetDateCreatedAsync(identity.ItemId, result.FirstAdded, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogDebug("Item {ItemId} existiert nicht mehr, keine Korrektur", identity.ItemId);
|
||||
return;
|
||||
}
|
||||
|
||||
_dryRunReported.TryRemove(identity.ItemId, out _);
|
||||
var record = new CorrectionRecord(identity.ItemId, identity.DisplayName, result.ItemDate, result.FirstAdded, UtcNow(), DryRun: false);
|
||||
if (_corrections.Add(record))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Upgrade erkannt: {Name} – DateAdded {New:u} → {Original:u}",
|
||||
identity.DisplayName,
|
||||
result.ItemDate,
|
||||
result.FirstAdded);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"DateAdded von {Name} wurde von Jellyfin erneut überschrieben und wieder auf {Original:u} gesetzt",
|
||||
identity.DisplayName,
|
||||
result.FirstAdded);
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime UtcNow() => _timeProvider.GetUtcNow().UtcDateTime;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// An external source for the first "date added" of a medium, e.g. the import history of Radarr or Sonarr.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Extension point for v2: implementations registered in the DI container are consulted by the
|
||||
/// ledger synchronization, and earlier dates lower the ledger via <see cref="Reconciler.ApplyExternalFirstAdded"/>.
|
||||
/// v1 ships no implementation.
|
||||
/// </remarks>
|
||||
public interface IExternalDateSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the name of the source for logs.
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Looks up when the medium was first imported.
|
||||
/// </summary>
|
||||
/// <param name="identity">The medium.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The first import date (UTC), or <c>null</c> if unknown.</returns>
|
||||
Task<DateTime?> GetFirstAddedAsync(MediaIdentity identity, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Writes the "date added" of a library item.
|
||||
/// </summary>
|
||||
public interface IItemDateWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the item's "date added" and saves it.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item.</param>
|
||||
/// <param name="dateCreated">The new "date added" (UTC).</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns><c>false</c> if the item no longer exists.</returns>
|
||||
Task<bool> TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Kind of logical medium tracked by the ledger.
|
||||
/// </summary>
|
||||
public enum MediaKind
|
||||
{
|
||||
/// <summary>A movie.</summary>
|
||||
Movie,
|
||||
|
||||
/// <summary>A TV episode (or a multi-episode file).</summary>
|
||||
Episode
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Jellyfin-independent snapshot of a library item, as consumed by the <see cref="Reconciler"/>.
|
||||
/// </summary>
|
||||
/// <param name="ItemId">The Jellyfin item id.</param>
|
||||
/// <param name="Kind">The kind of medium.</param>
|
||||
/// <param name="DateCreated">The item's current "date added" (UTC).</param>
|
||||
/// <param name="Keys">All identity keys of the logical medium; empty if the item cannot be identified yet.</param>
|
||||
/// <param name="DisplayName">Human readable name for logs and the configuration page.</param>
|
||||
public sealed record MediaIdentity(
|
||||
Guid ItemId,
|
||||
MediaKind Kind,
|
||||
DateTime DateCreated,
|
||||
IReadOnlyList<string> Keys,
|
||||
string DisplayName);
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the alias keys that identify a logical medium independent of its file path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keys are compared case-insensitively by the ledger, so provider ids are only trimmed.
|
||||
/// </remarks>
|
||||
public static class MediaKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the keys of a movie from its provider ids.
|
||||
/// </summary>
|
||||
/// <param name="tmdbId">The TMDb id, if known.</param>
|
||||
/// <param name="imdbId">The IMDb id, if known.</param>
|
||||
/// <param name="tvdbId">The TVDb id, if known.</param>
|
||||
/// <returns>The keys; empty if the movie has no provider id yet.</returns>
|
||||
public static IReadOnlyList<string> ForMovie(string? tmdbId, string? imdbId, string? tvdbId)
|
||||
{
|
||||
var keys = new List<string>(3);
|
||||
AddIfPresent(keys, "movie:tmdb:", tmdbId, string.Empty);
|
||||
AddIfPresent(keys, "movie:imdb:", imdbId, string.Empty);
|
||||
AddIfPresent(keys, "movie:tvdb:", tvdbId, string.Empty);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the episode part of an episode key, e.g. <c>s1e5</c> or <c>s1e5-6</c> for a double episode.
|
||||
/// </summary>
|
||||
/// <param name="seasonNumber">The season number (<c>ParentIndexNumber</c>).</param>
|
||||
/// <param name="episodeNumber">The episode number (<c>IndexNumber</c>).</param>
|
||||
/// <param name="episodeNumberEnd">The last episode number of a multi-episode file (<c>IndexNumberEnd</c>).</param>
|
||||
/// <returns>The episode part, or <c>null</c> if season or episode number is missing.</returns>
|
||||
public static string? EpisodePart(int? seasonNumber, int? episodeNumber, int? episodeNumberEnd)
|
||||
{
|
||||
if (seasonNumber is not { } season || episodeNumber is not { } episode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return episodeNumberEnd is { } end && end > episode
|
||||
? string.Create(CultureInfo.InvariantCulture, $"s{season}e{episode}-{end}")
|
||||
: string.Create(CultureInfo.InvariantCulture, $"s{season}e{episode}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the keys of an episode.
|
||||
/// </summary>
|
||||
/// <param name="episodePart">The episode part from <see cref="EpisodePart"/>.</param>
|
||||
/// <param name="seriesTvdbId">The series' TVDb id, if known.</param>
|
||||
/// <param name="seriesTmdbId">The series' TMDb id, if known.</param>
|
||||
/// <param name="seriesImdbId">The series' IMDb id, if known.</param>
|
||||
/// <param name="seriesId">The Jellyfin id of the series; <see cref="Guid.Empty"/> if unknown.</param>
|
||||
/// <returns>The keys; contains the series-guid key whenever the series id is known.</returns>
|
||||
public static IReadOnlyList<string> ForEpisode(
|
||||
string episodePart,
|
||||
string? seriesTvdbId,
|
||||
string? seriesTmdbId,
|
||||
string? seriesImdbId,
|
||||
Guid seriesId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(episodePart);
|
||||
|
||||
var suffix = ":" + episodePart;
|
||||
var keys = new List<string>(4);
|
||||
AddIfPresent(keys, "ep:tvdb:", seriesTvdbId, suffix);
|
||||
AddIfPresent(keys, "ep:tmdb:", seriesTmdbId, suffix);
|
||||
AddIfPresent(keys, "ep:imdb:", seriesImdbId, suffix);
|
||||
if (seriesId != Guid.Empty)
|
||||
{
|
||||
keys.Add("ep:series-guid:" + seriesId.ToString("N", CultureInfo.InvariantCulture) + suffix);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static void AddIfPresent(List<string> keys, string prefix, string? id, string suffix)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
keys.Add(prefix + id.Trim() + suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// What the <see cref="Reconciler"/> concluded for an item.
|
||||
/// </summary>
|
||||
public enum ReconcileAction
|
||||
{
|
||||
/// <summary>The item has no identity keys (yet) and was skipped.</summary>
|
||||
Ignored,
|
||||
|
||||
/// <summary>A new medium; a ledger entry was created with the item's date.</summary>
|
||||
Registered,
|
||||
|
||||
/// <summary>A known medium whose date matches the ledger.</summary>
|
||||
Unchanged,
|
||||
|
||||
/// <summary>The item is older than the ledger (e.g. a date from an NFO); the ledger took the earlier date.</summary>
|
||||
FirstAddedLowered,
|
||||
|
||||
/// <summary>The medium returned after the grace period; it counts as new and the ledger took its date.</summary>
|
||||
ReturnedAfterGrace,
|
||||
|
||||
/// <summary>The item is newer than the ledger: an upgrade whose date has to be restored.</summary>
|
||||
UpgradeDetected
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of reconciling one item with the ledger.
|
||||
/// </summary>
|
||||
/// <param name="Action">The conclusion.</param>
|
||||
/// <param name="Identity">The item.</param>
|
||||
/// <param name="ItemDate">The item's "date added" as evaluated (UTC).</param>
|
||||
/// <param name="FirstAdded">The ledger's first "date added" after reconciling (UTC).</param>
|
||||
public sealed record ReconcileResult(ReconcileAction Action, MediaIdentity Identity, DateTime ItemDate, DateTime FirstAdded);
|
||||
@@ -0,0 +1,329 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Matches items against the ledger and decides whether an item is new, known or an upgrade.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pure logic without Jellyfin types: it only updates the ledger and reports what it found.
|
||||
/// Changing items is up to the caller (see <see cref="GuardProcessor"/>).
|
||||
/// </remarks>
|
||||
public sealed class Reconciler
|
||||
{
|
||||
private readonly LedgerStore _store;
|
||||
private readonly Func<GuardOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<Reconciler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Reconciler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="store">The ledger.</param>
|
||||
/// <param name="options">Provides the current options.</param>
|
||||
/// <param name="timeProvider">The clock.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public Reconciler(LedgerStore store, Func<GuardOptions> options, TimeProvider timeProvider, ILogger<Reconciler> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles an added or updated item: registers it and detects upgrades.
|
||||
/// </summary>
|
||||
/// <param name="identity">The item.</param>
|
||||
/// <returns>The outcome; <see cref="ReconcileAction.UpgradeDetected"/> asks the caller to restore <see cref="ReconcileResult.FirstAdded"/>.</returns>
|
||||
public ReconcileResult OnItemSeen(MediaIdentity identity) => Apply(identity, detectUpgrades: true);
|
||||
|
||||
/// <summary>
|
||||
/// Registers an item during seed or synchronization. The ledger keeps the minimum date; upgrades are not reported.
|
||||
/// </summary>
|
||||
/// <param name="identity">The item.</param>
|
||||
/// <returns>The outcome.</returns>
|
||||
public ReconcileResult Register(MediaIdentity identity) => Apply(identity, detectUpgrades: false);
|
||||
|
||||
/// <summary>
|
||||
/// Handles a removed item. The entry is found by item id, or by key if the item was never registered.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The removed item.</param>
|
||||
/// <param name="keys">The keys the item still resolves to; may be empty.</param>
|
||||
/// <returns><c>true</c> if a ledger entry was affected.</returns>
|
||||
public bool OnItemRemoved(Guid itemId, IReadOnlyList<string> keys)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keys);
|
||||
|
||||
var now = UtcNow();
|
||||
lock (_store.SyncRoot)
|
||||
{
|
||||
var entry = _store.FindByItemId(itemId);
|
||||
if (entry is not null)
|
||||
{
|
||||
_store.DetachItem(entry, itemId);
|
||||
}
|
||||
else
|
||||
{
|
||||
var matches = keys.Count > 0 ? _store.FindByKeys(keys) : [];
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entry = matches[0];
|
||||
}
|
||||
|
||||
if (entry.LiveItemIds.Count == 0 && entry.RemovedAt is null)
|
||||
{
|
||||
entry.RemovedAt = now;
|
||||
_logger.LogDebug("Letztes Item von {Name} entfernt, Frist läuft ab {RemovedAt:u}", entry.DisplayName, now);
|
||||
}
|
||||
|
||||
_store.MarkDirty();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists live item ids the ledger knows but a synchronization did not see.
|
||||
/// </summary>
|
||||
/// <param name="seenItemIds">The items seen by the synchronization.</param>
|
||||
/// <returns>Candidates for <see cref="RemoveStaleItems"/>; the caller checks whether they still exist.</returns>
|
||||
public IReadOnlyList<Guid> GetUnseenLiveItemIds(IReadOnlySet<Guid> seenItemIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(seenItemIds);
|
||||
|
||||
lock (_store.SyncRoot)
|
||||
{
|
||||
return _store.GetEntries()
|
||||
.SelectMany(entry => entry.LiveItemIds)
|
||||
.Where(itemId => !seenItemIds.Contains(itemId))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes items that no longer exist from their entries and starts the grace period for emptied entries.
|
||||
/// </summary>
|
||||
/// <param name="itemIds">The items that no longer exist.</param>
|
||||
/// <returns>The number of detached items.</returns>
|
||||
public int RemoveStaleItems(IEnumerable<Guid> itemIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(itemIds);
|
||||
|
||||
var now = UtcNow();
|
||||
var detached = 0;
|
||||
lock (_store.SyncRoot)
|
||||
{
|
||||
foreach (var itemId in itemIds)
|
||||
{
|
||||
var entry = _store.FindByItemId(itemId);
|
||||
if (entry is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_store.DetachItem(entry, itemId);
|
||||
detached++;
|
||||
if (entry.LiveItemIds.Count == 0 && entry.RemovedAt is null)
|
||||
{
|
||||
entry.RemovedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
if (detached > 0)
|
||||
{
|
||||
_store.MarkDirty();
|
||||
}
|
||||
}
|
||||
|
||||
return detached;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes entries removed longer than the grace period plus one day; a return after that counts as new anyway.
|
||||
/// </summary>
|
||||
/// <returns>The number of deleted entries.</returns>
|
||||
public int Prune()
|
||||
{
|
||||
var now = UtcNow();
|
||||
var retention = _options().GracePeriod + TimeSpan.FromDays(1);
|
||||
var pruned = 0;
|
||||
var changed = false;
|
||||
lock (_store.SyncRoot)
|
||||
{
|
||||
foreach (var entry in _store.GetEntries())
|
||||
{
|
||||
if (entry.LiveItemIds.Count > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.RemovedAt is not { } removedAt)
|
||||
{
|
||||
// Lost its last item without a removal event; start the grace period now.
|
||||
entry.RemovedAt = now;
|
||||
changed = true;
|
||||
}
|
||||
else if (now - removedAt > retention)
|
||||
{
|
||||
_store.Remove(entry);
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
|
||||
if (pruned > 0 || changed)
|
||||
{
|
||||
_store.MarkDirty();
|
||||
}
|
||||
}
|
||||
|
||||
return pruned;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lowers the first "date added" of a known medium from an external source (e.g. the import history
|
||||
/// of Radarr/Sonarr). Never raises it and never changes items.
|
||||
/// </summary>
|
||||
/// <param name="keys">The keys of the medium.</param>
|
||||
/// <param name="firstAdded">The date reported by the source.</param>
|
||||
/// <returns><c>true</c> if the ledger was lowered.</returns>
|
||||
public bool ApplyExternalFirstAdded(IReadOnlyList<string> keys, DateTime firstAdded)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keys);
|
||||
|
||||
var date = LedgerStore.AsUtc(firstAdded);
|
||||
lock (_store.SyncRoot)
|
||||
{
|
||||
var entry = _store.FindByKeys(keys).MinBy(candidate => candidate.FirstAdded);
|
||||
if (entry is null || date >= entry.FirstAdded)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.FirstAdded = date;
|
||||
_store.MarkDirty();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private ReconcileResult Apply(MediaIdentity identity, bool detectUpgrades)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(identity);
|
||||
|
||||
var now = UtcNow();
|
||||
var itemDate = identity.DateCreated == DateTime.MinValue ? now : LedgerStore.AsUtc(identity.DateCreated);
|
||||
if (identity.Keys.Count == 0)
|
||||
{
|
||||
return new ReconcileResult(ReconcileAction.Ignored, identity, itemDate, itemDate);
|
||||
}
|
||||
|
||||
var options = _options();
|
||||
lock (_store.SyncRoot)
|
||||
{
|
||||
var candidates = _store.FindByKeys(identity.Keys);
|
||||
ReleaseFromOutdatedEntry(identity.ItemId, candidates);
|
||||
|
||||
var entry = candidates.Count switch
|
||||
{
|
||||
0 => null,
|
||||
1 => candidates[0],
|
||||
_ => Merge(candidates, now, options)
|
||||
};
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
entry = _store.Add(identity.Keys, itemDate, now, identity.DisplayName);
|
||||
_store.AttachItem(entry, identity.ItemId);
|
||||
_store.MarkDirty();
|
||||
return new ReconcileResult(ReconcileAction.Registered, identity, itemDate, entry.FirstAdded);
|
||||
}
|
||||
|
||||
var wasLive = entry.LiveItemIds.Contains(identity.ItemId);
|
||||
_store.AddKeys(entry, identity.Keys);
|
||||
_store.AttachItem(entry, identity.ItemId);
|
||||
entry.LastSeen = now;
|
||||
entry.DisplayName = identity.DisplayName;
|
||||
_store.MarkDirty();
|
||||
|
||||
if (entry.RemovedAt is { } removedAt && !wasLive && now - removedAt > options.GracePeriod)
|
||||
{
|
||||
// Deleted on purpose and fetched again later: a genuinely new addition.
|
||||
entry.RemovedAt = null;
|
||||
entry.FirstAdded = itemDate;
|
||||
return new ReconcileResult(ReconcileAction.ReturnedAfterGrace, identity, itemDate, itemDate);
|
||||
}
|
||||
|
||||
entry.RemovedAt = null;
|
||||
|
||||
// Seed and sync take the exact minimum, so the order of items does not matter.
|
||||
var tolerance = detectUpgrades ? options.Tolerance : TimeSpan.Zero;
|
||||
if (entry.FirstAdded - itemDate > tolerance)
|
||||
{
|
||||
entry.FirstAdded = itemDate;
|
||||
return new ReconcileResult(ReconcileAction.FirstAddedLowered, identity, itemDate, itemDate);
|
||||
}
|
||||
|
||||
if (detectUpgrades && itemDate - entry.FirstAdded > tolerance)
|
||||
{
|
||||
return new ReconcileResult(ReconcileAction.UpgradeDetected, identity, itemDate, entry.FirstAdded);
|
||||
}
|
||||
|
||||
return new ReconcileResult(ReconcileAction.Unchanged, identity, itemDate, entry.FirstAdded);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detaches the item from an entry its keys no longer match (e.g. after "Identify").
|
||||
/// The orphaned entry is dropped if the item was its only one, as the medium lives on under the new keys.
|
||||
/// </summary>
|
||||
private void ReleaseFromOutdatedEntry(Guid itemId, IReadOnlyList<LedgerEntry> candidates)
|
||||
{
|
||||
var previous = _store.FindByItemId(itemId);
|
||||
if (previous is null || candidates.Contains(previous))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_store.DetachItem(previous, itemId);
|
||||
if (previous.LiveItemIds.Count == 0)
|
||||
{
|
||||
_store.Remove(previous);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Item {ItemId} passt nicht mehr zu {Name} und wurde neu zugeordnet", itemId, previous.DisplayName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges several entries that turned out to be the same medium. Entries past their grace period
|
||||
/// only contribute keys, so a medium that returned as new does not inherit an old date.
|
||||
/// </summary>
|
||||
private LedgerEntry Merge(IReadOnlyList<LedgerEntry> candidates, DateTime now, GuardOptions options)
|
||||
{
|
||||
var active = candidates.Where(entry => !IsExpired(entry, now, options)).ToList();
|
||||
var pool = active.Count > 0 ? active : candidates;
|
||||
var target = pool.MinBy(entry => entry.FirstAdded)!;
|
||||
var latestRemoval = pool.Max(entry => entry.RemovedAt);
|
||||
|
||||
foreach (var other in candidates)
|
||||
{
|
||||
_store.Absorb(target, other);
|
||||
}
|
||||
|
||||
target.RemovedAt = target.LiveItemIds.Count > 0 ? null : latestRemoval;
|
||||
_logger.LogDebug("{Count} Ledger-Einträge zu {Name} zusammengeführt", candidates.Count, target.DisplayName);
|
||||
return target;
|
||||
}
|
||||
|
||||
private static bool IsExpired(LedgerEntry entry, DateTime now, GuardOptions options)
|
||||
=> entry.LiveItemIds.Count == 0
|
||||
&& entry.RemovedAt is { } removedAt
|
||||
&& now - removedAt > options.GracePeriod;
|
||||
|
||||
private DateTime UtcNow() => _timeProvider.GetUtcNow().UtcDateTime;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Jellyfin.Plugin.RecentlyAddedGuard</RootNamespace>
|
||||
<AssemblyName>Jellyfin.Plugin.RecentlyAddedGuard</AssemblyName>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Provided by the Jellyfin server at runtime; only the plugin DLL is shipped. -->
|
||||
<PackageReference Include="Jellyfin.Controller" Version="12.2.0" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="12.2.0" ExcludeAssets="runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Jellyfin.Plugin.RecentlyAddedGuard.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
||||
|
||||
/// <summary>
|
||||
/// The first "date added" of one logical medium (a movie or an episode), independent of its files.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Keys"/> and <see cref="LiveItemIds"/> are indexed by the <see cref="LedgerStore"/>;
|
||||
/// change them only through the store so the indexes stay consistent.
|
||||
/// </remarks>
|
||||
public sealed class LedgerEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets all alias keys of the medium (e.g. <c>movie:tmdb:603</c>).
|
||||
/// </summary>
|
||||
#pragma warning disable CA1819 // Plain persistence model; the store owns all mutations.
|
||||
public string[] Keys { get; set; } = [];
|
||||
#pragma warning restore CA1819
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protected first "date added" (UTC).
|
||||
/// </summary>
|
||||
public DateTime FirstAdded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when an item of this medium was last seen (UTC).
|
||||
/// </summary>
|
||||
public DateTime LastSeen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the last live item of this medium disappeared (UTC); <c>null</c> while items exist.
|
||||
/// </summary>
|
||||
public DateTime? RemovedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ids of the Jellyfin items currently representing this medium.
|
||||
/// </summary>
|
||||
#pragma warning disable CA2227 // Plain persistence model; the store owns all mutations.
|
||||
public HashSet<Guid> LiveItemIds { get; set; } = [];
|
||||
#pragma warning restore CA2227
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a display name for logs and the configuration page.
|
||||
/// </summary>
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
||||
|
||||
/// <summary>
|
||||
/// Counters describing the ledger.
|
||||
/// </summary>
|
||||
/// <param name="Entries">All entries.</param>
|
||||
/// <param name="LiveEntries">Entries with at least one live item.</param>
|
||||
/// <param name="RemovedEntries">Entries whose items are gone (kept for the grace period).</param>
|
||||
public sealed record LedgerStatistics(int Entries, int LiveEntries, int RemovedEntries);
|
||||
@@ -0,0 +1,641 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe, persistent ledger with indexes by alias key and by live item id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All state is guarded by <see cref="SyncRoot"/> (re-entrant). Writes are debounced and atomic:
|
||||
/// the JSON goes to a temporary file first, which then replaces the ledger file.
|
||||
/// </remarks>
|
||||
public sealed class LedgerStore : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The file name of the ledger inside the plugin data folder.
|
||||
/// </summary>
|
||||
public const string DefaultFileName = "ledger.json";
|
||||
|
||||
private const int SchemaVersion = 1;
|
||||
|
||||
private static readonly TimeSpan _defaultSaveDelay = TimeSpan.FromSeconds(5);
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = false };
|
||||
|
||||
private readonly Lock _sync = new();
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
private readonly List<LedgerEntry> _entries = [];
|
||||
private readonly Dictionary<string, LedgerEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<Guid, LedgerEntry> _byItemId = [];
|
||||
private readonly ILogger<LedgerStore> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _saveDelay;
|
||||
private readonly ITimer _saveTimer;
|
||||
private bool _seedCompleted;
|
||||
private DateTime? _lastSyncUtc;
|
||||
private bool _dirty;
|
||||
private bool _saveScheduled;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LedgerStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The path of the ledger file.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="timeProvider">The time provider; defaults to the system clock.</param>
|
||||
/// <param name="saveDelay">How long changes are collected before they are written; defaults to 5 seconds.</param>
|
||||
public LedgerStore(string filePath, ILogger<LedgerStore> logger, TimeProvider? timeProvider = null, TimeSpan? saveDelay = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
FilePath = filePath;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_saveDelay = saveDelay ?? _defaultSaveDelay;
|
||||
_saveTimer = _timeProvider.CreateTimer(_ => OnSaveTimer(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path of the ledger file.
|
||||
/// </summary>
|
||||
public string FilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the initial seed has completed.
|
||||
/// </summary>
|
||||
public bool SeedCompleted
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _seedCompleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time of the last completed seed or synchronization (UTC).
|
||||
/// </summary>
|
||||
public DateTime? LastSyncUtc
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _lastSyncUtc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the lock guarding the ledger. It is re-entrant, so multi-step operations can wrap store calls.
|
||||
/// </summary>
|
||||
internal Lock SyncRoot => _sync;
|
||||
|
||||
/// <summary>
|
||||
/// Gets counters for the status page.
|
||||
/// </summary>
|
||||
/// <returns>The current statistics.</returns>
|
||||
public LedgerStatistics GetStatistics()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var live = 0;
|
||||
var removed = 0;
|
||||
foreach (var entry in _entries)
|
||||
{
|
||||
if (entry.LiveItemIds.Count > 0)
|
||||
{
|
||||
live++;
|
||||
}
|
||||
else if (entry.RemovedAt is not null)
|
||||
{
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
return new LedgerStatistics(_entries.Count, live, removed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the ledger from disk, replacing the in-memory state. A missing file yields an empty ledger;
|
||||
/// an unreadable file is moved aside so the seed runs again.
|
||||
/// </summary>
|
||||
public void Load()
|
||||
{
|
||||
TryDelete(TempPath);
|
||||
|
||||
LedgerDocument? document = null;
|
||||
if (File.Exists(FilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(FilePath);
|
||||
document = JsonSerializer.Deserialize<LedgerDocument>(stream, _jsonOptions)
|
||||
?? throw new JsonException("The ledger file is empty.");
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or NotSupportedException)
|
||||
{
|
||||
var backupPath = FilePath + ".corrupt-"
|
||||
+ _timeProvider.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
|
||||
TryMove(FilePath, backupPath);
|
||||
_logger.LogError(ex, "Ledger {Path} ist nicht lesbar und wurde nach {Backup} verschoben, der Seed läuft erneut", FilePath, backupPath);
|
||||
document = null;
|
||||
}
|
||||
}
|
||||
|
||||
int count;
|
||||
lock (_sync)
|
||||
{
|
||||
_entries.Clear();
|
||||
_byKey.Clear();
|
||||
_byItemId.Clear();
|
||||
_seedCompleted = document?.SeedCompleted ?? false;
|
||||
_lastSyncUtc = document?.LastSyncUtc is { } lastSync ? AsUtc(lastSync) : null;
|
||||
_dirty = false;
|
||||
|
||||
foreach (var loaded in document?.Entries ?? [])
|
||||
{
|
||||
Restore(loaded);
|
||||
}
|
||||
|
||||
count = _entries.Count;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Ledger geladen: {Count} Einträge, Seed abgeschlossen: {SeedCompleted}", count, document?.SeedCompleted ?? false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests a debounced save. Several changes within the save delay result in a single write.
|
||||
/// </summary>
|
||||
public void MarkDirty()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_dirty = true;
|
||||
if (_saveScheduled || _disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_saveScheduled = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_saveTimer.Change(_saveDelay, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Disposed concurrently; the final flush happens in Dispose.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes pending changes immediately.
|
||||
/// </summary>
|
||||
public void Flush() => SaveIfDirty();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Flush();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogError(ex, "Ledger konnte beim Beenden nicht gespeichert werden: {Path}", FilePath);
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
_saveTimer.Dispose();
|
||||
_writeGate.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds all distinct entries that own at least one of the keys.
|
||||
/// </summary>
|
||||
/// <param name="keys">The keys to look up.</param>
|
||||
/// <returns>The matching entries, in key order.</returns>
|
||||
internal IReadOnlyList<LedgerEntry> FindByKeys(IEnumerable<string> keys)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
List<LedgerEntry>? result = null;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (_byKey.TryGetValue(key, out var entry) && (result is null || !result.Contains(entry)))
|
||||
{
|
||||
(result ??= []).Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return result ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the entry that currently lists the item as live.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <returns>The entry, or <c>null</c>.</returns>
|
||||
internal LedgerEntry? FindByItemId(Guid itemId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _byItemId.GetValueOrDefault(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new entry without live items.
|
||||
/// </summary>
|
||||
/// <param name="keys">The keys; keys owned by other entries are skipped.</param>
|
||||
/// <param name="firstAdded">The first "date added".</param>
|
||||
/// <param name="now">The current time.</param>
|
||||
/// <param name="displayName">The display name.</param>
|
||||
/// <returns>The new entry.</returns>
|
||||
internal LedgerEntry Add(IEnumerable<string> keys, DateTime firstAdded, DateTime now, string displayName)
|
||||
{
|
||||
var entry = new LedgerEntry
|
||||
{
|
||||
FirstAdded = AsUtc(firstAdded),
|
||||
LastSeen = AsUtc(now),
|
||||
DisplayName = displayName
|
||||
};
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_entries.Add(entry);
|
||||
AddKeys(entry, keys);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds keys to an entry. Keys that already belong to another entry are skipped,
|
||||
/// so callers merge all matching entries first.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry.</param>
|
||||
/// <param name="keys">The keys.</param>
|
||||
internal void AddKeys(LedgerEntry entry, IEnumerable<string> keys)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
List<string>? added = null;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || _byKey.ContainsKey(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_byKey[key] = entry;
|
||||
(added ??= []).Add(key);
|
||||
}
|
||||
|
||||
if (added is not null)
|
||||
{
|
||||
entry.Keys = [.. entry.Keys, .. added];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the item as a live item of the entry, moving it away from any other entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry.</param>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
internal void AttachItem(LedgerEntry entry, Guid itemId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_byItemId.TryGetValue(itemId, out var owner) && !ReferenceEquals(owner, entry))
|
||||
{
|
||||
owner.LiveItemIds.Remove(itemId);
|
||||
}
|
||||
|
||||
entry.LiveItemIds.Add(itemId);
|
||||
_byItemId[itemId] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the item from the live items of the entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry.</param>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
internal void DetachItem(LedgerEntry entry, Guid itemId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
entry.LiveItemIds.Remove(itemId);
|
||||
if (_byItemId.TryGetValue(itemId, out var owner) && ReferenceEquals(owner, entry))
|
||||
{
|
||||
_byItemId.Remove(itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an entry and its index entries.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry.</param>
|
||||
internal void Remove(LedgerEntry entry)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_entries.Remove(entry))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var key in entry.Keys)
|
||||
{
|
||||
if (_byKey.TryGetValue(key, out var owner) && ReferenceEquals(owner, entry))
|
||||
{
|
||||
_byKey.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var itemId in entry.LiveItemIds)
|
||||
{
|
||||
if (_byItemId.TryGetValue(itemId, out var owner) && ReferenceEquals(owner, entry))
|
||||
{
|
||||
_byItemId.Remove(itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves keys and live items of <paramref name="source"/> into <paramref name="target"/> and removes the source.
|
||||
/// Dates are left to the caller.
|
||||
/// </summary>
|
||||
/// <param name="target">The surviving entry.</param>
|
||||
/// <param name="source">The entry to merge into the target.</param>
|
||||
internal void Absorb(LedgerEntry target, LedgerEntry source)
|
||||
{
|
||||
if (ReferenceEquals(target, source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
var keys = source.Keys;
|
||||
var itemIds = source.LiveItemIds.ToArray();
|
||||
Remove(source);
|
||||
AddKeys(target, keys);
|
||||
foreach (var itemId in itemIds)
|
||||
{
|
||||
AttachItem(target, itemId);
|
||||
}
|
||||
|
||||
if (source.LastSeen > target.LastSeen)
|
||||
{
|
||||
target.LastSeen = source.LastSeen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of the entry references for iteration outside the lock.
|
||||
/// </summary>
|
||||
/// <returns>The entries.</returns>
|
||||
internal IReadOnlyList<LedgerEntry> GetEntries()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return [.. _entries];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a completed seed or synchronization.
|
||||
/// </summary>
|
||||
/// <param name="now">The completion time.</param>
|
||||
internal void MarkSynced(DateTime now)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_seedCompleted = true;
|
||||
_lastSyncUtc = AsUtc(now);
|
||||
}
|
||||
|
||||
MarkDirty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a timestamp to UTC; unspecified values are taken as UTC.
|
||||
/// </summary>
|
||||
/// <param name="value">The value.</param>
|
||||
/// <returns>The UTC value.</returns>
|
||||
internal static DateTime AsUtc(DateTime value) => value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
private string TempPath => FilePath + ".tmp";
|
||||
|
||||
private void Restore(LedgerEntry loaded)
|
||||
{
|
||||
var keys = (loaded.Keys ?? [])
|
||||
.Where(key => !string.IsNullOrWhiteSpace(key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (keys.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var itemIds = loaded.LiveItemIds ?? [];
|
||||
var firstAdded = AsUtc(loaded.FirstAdded);
|
||||
var lastSeen = AsUtc(loaded.LastSeen);
|
||||
DateTime? removedAt = loaded.RemovedAt is { } removed ? AsUtc(removed) : null;
|
||||
|
||||
// Overlapping entries can only come from an older or damaged file: merge them, keeping the earliest date.
|
||||
var existing = keys.Select(key => _byKey.GetValueOrDefault(key)).FirstOrDefault(entry => entry is not null);
|
||||
if (existing is not null)
|
||||
{
|
||||
AddKeys(existing, keys);
|
||||
foreach (var itemId in itemIds)
|
||||
{
|
||||
AttachItem(existing, itemId);
|
||||
}
|
||||
|
||||
if (firstAdded < existing.FirstAdded)
|
||||
{
|
||||
existing.FirstAdded = firstAdded;
|
||||
}
|
||||
|
||||
if (lastSeen > existing.LastSeen)
|
||||
{
|
||||
existing.LastSeen = lastSeen;
|
||||
}
|
||||
|
||||
existing.RemovedAt = existing.LiveItemIds.Count > 0 ? null : existing.RemovedAt ?? removedAt;
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = new LedgerEntry
|
||||
{
|
||||
FirstAdded = firstAdded,
|
||||
LastSeen = lastSeen,
|
||||
RemovedAt = itemIds.Count > 0 ? null : removedAt,
|
||||
DisplayName = loaded.DisplayName ?? string.Empty
|
||||
};
|
||||
_entries.Add(entry);
|
||||
AddKeys(entry, keys);
|
||||
foreach (var itemId in itemIds)
|
||||
{
|
||||
AttachItem(entry, itemId);
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable CA1031 // A timer callback must never throw; failures are logged and retried.
|
||||
private void OnSaveTimer()
|
||||
{
|
||||
try
|
||||
{
|
||||
SaveIfDirty();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Shutting down.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Ledger konnte nicht gespeichert werden: {Path}", FilePath);
|
||||
MarkDirty();
|
||||
}
|
||||
}
|
||||
#pragma warning restore CA1031
|
||||
|
||||
private void SaveIfDirty()
|
||||
{
|
||||
_writeGate.Wait();
|
||||
try
|
||||
{
|
||||
byte[] payload;
|
||||
lock (_sync)
|
||||
{
|
||||
_saveScheduled = false;
|
||||
if (!_dirty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
payload = JsonSerializer.SerializeToUtf8Bytes(
|
||||
new LedgerDocument
|
||||
{
|
||||
SchemaVersion = SchemaVersion,
|
||||
SeedCompleted = _seedCompleted,
|
||||
LastSyncUtc = _lastSyncUtc,
|
||||
Entries = _entries
|
||||
},
|
||||
_jsonOptions);
|
||||
_dirty = false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WriteAtomically(payload);
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteAtomically(byte[] payload)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(FilePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
using (var stream = new FileStream(TempPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
stream.Write(payload);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
File.Move(TempPath, FilePath, overwrite: true);
|
||||
_logger.LogDebug("Ledger gespeichert: {Path} ({Bytes} Bytes)", FilePath, payload.Length);
|
||||
}
|
||||
|
||||
private void TryMove(string source, string destination)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Move(source, destination, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Datei {Source} konnte nicht verschoben werden", source);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Temporäre Datei {Path} konnte nicht gelöscht werden", path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The on-disk format of the ledger.
|
||||
/// </summary>
|
||||
private sealed class LedgerDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public bool SeedCompleted { get; set; }
|
||||
|
||||
public DateTime? LastSyncUtc { get; set; }
|
||||
|
||||
public List<LedgerEntry> Entries { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps quality upgrades from Radarr and Sonarr out of "Recently Added".
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// The permanent plugin id. Never change it: it identifies the plugin in the catalog and in installations.
|
||||
/// </summary>
|
||||
public const string PluginGuid = "6fdfedef-adde-4ac3-a873-62a057c95574";
|
||||
|
||||
private static readonly Guid _pluginId = Guid.Parse(PluginGuid);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">The application paths.</param>
|
||||
/// <param name="xmlSerializer">The XML serializer.</param>
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin instance; set once Jellyfin has created the plugin.
|
||||
/// </summary>
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Recently Added Guard";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => _pluginId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description
|
||||
=> "Hält Qualitäts-Upgrades von Radarr und Sonarr aus „Zuletzt hinzugefügt“ fern, indem es das erste Hinzufügedatum pro Film und Episode bewahrt.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current configuration, or the defaults before the plugin is loaded.
|
||||
/// </summary>
|
||||
internal static PluginConfiguration CurrentConfiguration => Instance?.Configuration ?? new PluginConfiguration();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
yield return new PluginPageInfo
|
||||
{
|
||||
Name = "RecentlyAddedGuard",
|
||||
DisplayName = Name,
|
||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Services;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the plugin services with Jellyfin's DI container.
|
||||
/// </summary>
|
||||
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
// Runs before the plugin instance exists; factories are evaluated later, when the host starts.
|
||||
serviceCollection.AddSingleton<CorrectionLog>();
|
||||
serviceCollection.AddSingleton(sp => new LedgerStore(
|
||||
Path.Combine(GetDataFolderPath(sp), LedgerStore.DefaultFileName),
|
||||
sp.GetRequiredService<ILogger<LedgerStore>>()));
|
||||
serviceCollection.AddSingleton(sp => new Reconciler(
|
||||
sp.GetRequiredService<LedgerStore>(),
|
||||
GetOptions,
|
||||
TimeProvider.System,
|
||||
sp.GetRequiredService<ILogger<Reconciler>>()));
|
||||
serviceCollection.AddSingleton<IdentityResolver>();
|
||||
serviceCollection.AddSingleton<LibraryItemDateWriter>();
|
||||
serviceCollection.AddSingleton(sp => new GuardProcessor(
|
||||
sp.GetRequiredService<Reconciler>(),
|
||||
sp.GetRequiredService<LibraryItemDateWriter>(),
|
||||
sp.GetRequiredService<CorrectionLog>(),
|
||||
GetOptions,
|
||||
TimeProvider.System,
|
||||
sp.GetRequiredService<ILogger<GuardProcessor>>()));
|
||||
serviceCollection.AddSingleton<LedgerSynchronizer>();
|
||||
serviceCollection.AddHostedService<GuardHostedService>();
|
||||
}
|
||||
|
||||
private static GuardOptions GetOptions() => Plugin.CurrentConfiguration.ToGuardOptions();
|
||||
|
||||
private static string GetDataFolderPath(IServiceProvider serviceProvider)
|
||||
=> Plugin.Instance?.DataFolderPath
|
||||
?? Path.Combine(serviceProvider.GetRequiredService<IApplicationPaths>().PluginsPath, typeof(Plugin).Assembly.GetName().Name!);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Listens to library events and processes them on a background worker; runs the seed on first start.
|
||||
/// </summary>
|
||||
public sealed class GuardHostedService : IHostedService, IDisposable
|
||||
{
|
||||
private static readonly TimeSpan _debounceDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IdentityResolver _resolver;
|
||||
private readonly GuardProcessor _processor;
|
||||
private readonly LibraryItemDateWriter _writer;
|
||||
private readonly LedgerStore _store;
|
||||
private readonly LedgerSynchronizer _synchronizer;
|
||||
private readonly ILogger<GuardHostedService> _logger;
|
||||
private readonly Channel<GuardEvent> _events = Channel.CreateUnbounded<GuardEvent>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||
|
||||
private readonly CancellationTokenSource _stopping = new();
|
||||
private Task _seed = Task.CompletedTask;
|
||||
private Task _worker = Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GuardHostedService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="resolver">The identity resolver.</param>
|
||||
/// <param name="processor">The processor.</param>
|
||||
/// <param name="writer">The date writer.</param>
|
||||
/// <param name="store">The ledger.</param>
|
||||
/// <param name="synchronizer">The synchronizer.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public GuardHostedService(
|
||||
ILibraryManager libraryManager,
|
||||
IdentityResolver resolver,
|
||||
GuardProcessor processor,
|
||||
LibraryItemDateWriter writer,
|
||||
LedgerStore store,
|
||||
LedgerSynchronizer synchronizer,
|
||||
ILogger<GuardHostedService> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_resolver = resolver;
|
||||
_processor = processor;
|
||||
_writer = writer;
|
||||
_store = store;
|
||||
_synchronizer = synchronizer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_store.Load();
|
||||
|
||||
_libraryManager.ItemAdded += OnItemAddedOrUpdated;
|
||||
_libraryManager.ItemUpdated += OnItemAddedOrUpdated;
|
||||
_libraryManager.ItemRemoved += OnItemRemoved;
|
||||
|
||||
if (!_store.SeedCompleted)
|
||||
{
|
||||
_seed = Task.Run(() => RunSeedAsync(_stopping.Token), CancellationToken.None);
|
||||
}
|
||||
|
||||
_worker = Task.Run(() => RunWorkerAsync(_stopping.Token), CancellationToken.None);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_libraryManager.ItemAdded -= OnItemAddedOrUpdated;
|
||||
_libraryManager.ItemUpdated -= OnItemAddedOrUpdated;
|
||||
_libraryManager.ItemRemoved -= OnItemRemoved;
|
||||
_events.Writer.TryComplete();
|
||||
|
||||
// An unfinished seed starts over on the next start; pending events are still processed.
|
||||
if (!_seed.IsCompleted)
|
||||
{
|
||||
await _stopping.CancelAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(_seed, _worker).WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await _stopping.CancelAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_store.Flush();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogError(ex, "Ledger konnte beim Beenden nicht gespeichert werden");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _stopping.Dispose();
|
||||
|
||||
private void OnItemAddedOrUpdated(object? sender, ItemChangeEventArgs e)
|
||||
{
|
||||
var item = e.Item;
|
||||
if (item is null || !IdentityResolver.IsTracked(item) || _writer.IsWriting(item.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_events.Writer.TryWrite(new GuardEvent(item.Id, null));
|
||||
}
|
||||
|
||||
private void OnItemRemoved(object? sender, ItemChangeEventArgs e)
|
||||
{
|
||||
var item = e.Item;
|
||||
if (item is null || !IdentityResolver.IsTracked(item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the object: its provider ids are needed later, the database row is already gone.
|
||||
_events.Writer.TryWrite(new GuardEvent(item.Id, item));
|
||||
}
|
||||
|
||||
#pragma warning disable CA1031 // Background loops log failures and keep running.
|
||||
private async Task RunSeedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Ledger wurde noch nicht befüllt, Seed läuft im Hintergrund");
|
||||
await _synchronizer.RunAsync(new Progress<double>(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Ledger-Seed abgebrochen, er läuft beim nächsten Start erneut");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Ledger-Seed fehlgeschlagen; der Scheduled Task \"Ledger synchronisieren\" holt ihn nach");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunWorkerAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Events raised during the seed wait in the channel, so they are matched against a complete ledger.
|
||||
await _seed.ConfigureAwait(false);
|
||||
|
||||
var reader = _events.Reader;
|
||||
var pending = new OrderedDictionary<Guid, GuardEvent>();
|
||||
while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// A scan raises several events per item within moments; collect the burst, keep the latest per item.
|
||||
await Task.Delay(_debounceDelay, cancellationToken).ConfigureAwait(false);
|
||||
while (reader.TryRead(out var guardEvent))
|
||||
{
|
||||
pending[guardEvent.ItemId] = guardEvent;
|
||||
}
|
||||
|
||||
foreach (var guardEvent in pending.Values)
|
||||
{
|
||||
await ProcessAsync(guardEvent, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
pending.Clear();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Shutting down.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Event-Verarbeitung von Recently Added Guard wurde unerwartet beendet");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessAsync(GuardEvent guardEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (guardEvent.RemovedItem is { } removed)
|
||||
{
|
||||
_processor.ProcessRemoved(guardEvent.ItemId, _resolver.ResolveKeysForRemoval(removed));
|
||||
return;
|
||||
}
|
||||
|
||||
var item = _libraryManager.GetItemById(guardEvent.ItemId);
|
||||
var identity = item is null ? null : _resolver.Resolve(item);
|
||||
if (identity is not null)
|
||||
{
|
||||
await _processor.ProcessSeenAsync(identity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Item {ItemId} konnte nicht verarbeitet werden", guardEvent.ItemId);
|
||||
}
|
||||
}
|
||||
#pragma warning restore CA1031
|
||||
|
||||
/// <summary>
|
||||
/// A queued library event. <see cref="RemovedItem"/> is set for removals.
|
||||
/// </summary>
|
||||
private sealed record GuardEvent(Guid ItemId, BaseItem? RemovedItem);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds and synchronizes the ledger with the library. Only registers items; never changes them.
|
||||
/// </summary>
|
||||
public sealed class LedgerSynchronizer : IDisposable
|
||||
{
|
||||
private const int BatchSize = 500;
|
||||
|
||||
private static readonly BaseItemKind[] _trackedKinds = [BaseItemKind.Movie, BaseItemKind.Episode];
|
||||
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IdentityResolver _resolver;
|
||||
private readonly Reconciler _reconciler;
|
||||
private readonly LedgerStore _store;
|
||||
private readonly IExternalDateSource[] _externalSources;
|
||||
private readonly ILogger<LedgerSynchronizer> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LedgerSynchronizer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="resolver">The identity resolver.</param>
|
||||
/// <param name="reconciler">The reconciler.</param>
|
||||
/// <param name="store">The ledger.</param>
|
||||
/// <param name="externalSources">External date sources (none in v1).</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public LedgerSynchronizer(
|
||||
ILibraryManager libraryManager,
|
||||
IdentityResolver resolver,
|
||||
Reconciler reconciler,
|
||||
LedgerStore store,
|
||||
IEnumerable<IExternalDateSource> externalSources,
|
||||
ILogger<LedgerSynchronizer> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_resolver = resolver;
|
||||
_reconciler = reconciler;
|
||||
_store = store;
|
||||
_externalSources = externalSources.ToArray();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers all movies and episodes, detaches items that no longer exist and prunes old entries.
|
||||
/// The first run on an empty ledger is the seed. Concurrent calls wait for each other.
|
||||
/// </summary>
|
||||
/// <param name="progress">Reports progress from 0 to 100.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that completes when the ledger is synchronized and saved.</returns>
|
||||
public async Task RunAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(progress);
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await RunCoreAsync(progress, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _gate.Dispose();
|
||||
|
||||
private async Task RunCoreAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var isSeed = !_store.SeedCompleted;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
progress.Report(0);
|
||||
|
||||
// Ids first, then the items in batches: keeps memory flat for libraries with tens of thousands of episodes.
|
||||
var itemIds = _libraryManager.GetItemIds(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = _trackedKinds,
|
||||
Recursive = true,
|
||||
IsVirtualItem = false
|
||||
});
|
||||
|
||||
var seen = new HashSet<Guid>(itemIds.Count);
|
||||
var seriesCache = new Dictionary<Guid, Series?>();
|
||||
int registered = 0, lowered = 0, ignored = 0, external = 0;
|
||||
|
||||
for (var offset = 0; offset < itemIds.Count; offset += BatchSize)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var batch = itemIds.Skip(offset).Take(BatchSize).ToArray();
|
||||
var items = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
ItemIds = batch,
|
||||
IncludeItemTypes = _trackedKinds,
|
||||
Recursive = true,
|
||||
IsVirtualItem = false
|
||||
});
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
seen.Add(item.Id);
|
||||
var identity = _resolver.Resolve(item, seriesId => LookupSeries(seriesId, seriesCache));
|
||||
if (identity is null || identity.Keys.Count == 0)
|
||||
{
|
||||
ignored++;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (_reconciler.Register(identity).Action)
|
||||
{
|
||||
case ReconcileAction.Registered:
|
||||
registered++;
|
||||
break;
|
||||
case ReconcileAction.FirstAddedLowered:
|
||||
lowered++;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (_externalSources.Length > 0 && await ApplyExternalSourcesAsync(identity, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
external++;
|
||||
}
|
||||
}
|
||||
|
||||
progress.Report(90.0 * Math.Min(offset + BatchSize, itemIds.Count) / itemIds.Count);
|
||||
}
|
||||
|
||||
// Live items the query did not return may be alternate versions or added meanwhile; only drop the ones that are gone.
|
||||
var stale = _reconciler.GetUnseenLiveItemIds(seen)
|
||||
.Where(itemId => _libraryManager.GetItemById(itemId) is null)
|
||||
.ToList();
|
||||
var detached = _reconciler.RemoveStaleItems(stale);
|
||||
var pruned = _reconciler.Prune();
|
||||
|
||||
_store.MarkSynced(DateTime.UtcNow);
|
||||
_store.Flush();
|
||||
progress.Report(100);
|
||||
|
||||
var statistics = _store.GetStatistics();
|
||||
_logger.LogInformation(
|
||||
"{Kind} abgeschlossen in {Elapsed:g}: {Items} Items geprüft, {Registered} neu registriert, {Lowered} Datum vorverlegt, "
|
||||
+ "{External} aus externen Quellen, {Ignored} ohne Kennung übersprungen, {Detached} entfernte Items ausgetragen, {Pruned} Einträge bereinigt. "
|
||||
+ "Ledger: {Entries} Einträge ({Live} mit Items, {Removed} in der Frist)",
|
||||
isSeed ? "Ledger-Seed" : "Ledger-Synchronisation",
|
||||
stopwatch.Elapsed,
|
||||
itemIds.Count,
|
||||
registered,
|
||||
lowered,
|
||||
external,
|
||||
ignored,
|
||||
detached,
|
||||
pruned,
|
||||
statistics.Entries,
|
||||
statistics.LiveEntries,
|
||||
statistics.RemovedEntries);
|
||||
}
|
||||
|
||||
private Series? LookupSeries(Guid seriesId, Dictionary<Guid, Series?> cache)
|
||||
{
|
||||
if (!cache.TryGetValue(seriesId, out var series))
|
||||
{
|
||||
series = _libraryManager.GetItemById(seriesId) as Series;
|
||||
cache[seriesId] = series;
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
#pragma warning disable CA1031 // A failing external source must not abort the synchronization.
|
||||
private async Task<bool> ApplyExternalSourcesAsync(MediaIdentity identity, CancellationToken cancellationToken)
|
||||
{
|
||||
var lowered = false;
|
||||
foreach (var source in _externalSources)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await source.GetFirstAddedAsync(identity, cancellationToken).ConfigureAwait(false) is { } firstAdded)
|
||||
{
|
||||
lowered |= _reconciler.ApplyExternalFirstAdded(identity.Keys, firstAdded);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Externe Datumsquelle {Source} ist für {Name} fehlgeschlagen", source.Name, identity.DisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
return lowered;
|
||||
}
|
||||
#pragma warning restore CA1031
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
using MediaBrowser.Controller.Library;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Writes "date added" through the library manager and remembers which items it is currently saving,
|
||||
/// so the resulting <c>ItemUpdated</c> events can be ignored.
|
||||
/// </summary>
|
||||
public sealed class LibraryItemDateWriter : IItemDateWriter
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ConcurrentDictionary<Guid, byte> _inFlight = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LibraryItemDateWriter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
public LibraryItemDateWriter(ILibraryManager libraryManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the item is being saved by this writer right now.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item.</param>
|
||||
/// <returns><c>true</c> while a correction of the item is in progress.</returns>
|
||||
public bool IsWriting(Guid itemId) => _inFlight.ContainsKey(itemId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken)
|
||||
{
|
||||
var item = _libraryManager.GetItemById(itemId);
|
||||
if (item is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_inFlight.TryAdd(itemId, 0);
|
||||
try
|
||||
{
|
||||
item.DateCreated = dateCreated;
|
||||
|
||||
// MetadataEdit also lets the NFO saver (if enabled) write the restored <dateadded>.
|
||||
await _libraryManager.UpdateItemAsync(item, item.GetParent(), ItemUpdateType.MetadataEdit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inFlight.TryRemove(itemId, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Services;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled task "Ledger synchronisieren": registers unknown items, updates live items and prunes the ledger.
|
||||
/// It never changes library items.
|
||||
/// </summary>
|
||||
public class SyncLedgerTask : IScheduledTask
|
||||
{
|
||||
/// <summary>
|
||||
/// The task key.
|
||||
/// </summary>
|
||||
public const string TaskKey = "RecentlyAddedGuardSyncLedger";
|
||||
|
||||
private readonly LedgerSynchronizer _synchronizer;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SyncLedgerTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="synchronizer">The synchronizer.</param>
|
||||
public SyncLedgerTask(LedgerSynchronizer synchronizer)
|
||||
{
|
||||
_synchronizer = synchronizer;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Ledger synchronisieren";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => TaskKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description
|
||||
=> "Gleicht das Ledger von Recently Added Guard mit der Bibliothek ab: registriert unbekannte Filme und Episoden, "
|
||||
+ "trägt gelöschte Items aus und bereinigt abgelaufene Einträge. Ändert keine Items.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Category => "Recently Added Guard";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
=> _synchronizer.RunAsync(progress, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
yield return new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfoType.DailyTrigger,
|
||||
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user