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;
///
/// Seeds and synchronizes the ledger with the library. Only registers items; never changes them.
///
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 _logger;
///
/// Initializes a new instance of the class.
///
/// The library manager.
/// The identity resolver.
/// The reconciler.
/// The ledger.
/// External date sources (none in v1).
/// The logger.
public LedgerSynchronizer(
ILibraryManager libraryManager,
IdentityResolver resolver,
Reconciler reconciler,
LedgerStore store,
IEnumerable externalSources,
ILogger logger)
{
_libraryManager = libraryManager;
_resolver = resolver;
_reconciler = reconciler;
_store = store;
_externalSources = externalSources.ToArray();
_logger = logger;
}
///
/// 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.
///
/// Reports progress from 0 to 100.
/// The cancellation token.
/// A task that completes when the ledger is synchronized and saved.
public async Task RunAsync(IProgress progress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(progress);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await RunCoreAsync(progress, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
///
public void Dispose() => _gate.Dispose();
private async Task RunCoreAsync(IProgress 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(itemIds.Count);
var seriesCache = new Dictionary();
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 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 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
}