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;
///
/// Listens to library events and processes them on a background worker; runs the seed on first start.
///
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 _logger;
private readonly Channel _events = Channel.CreateUnbounded(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
private readonly CancellationTokenSource _stopping = new();
private Task _seed = Task.CompletedTask;
private Task _worker = Task.CompletedTask;
///
/// Initializes a new instance of the class.
///
/// The library manager.
/// The identity resolver.
/// The processor.
/// The date writer.
/// The ledger.
/// The synchronizer.
/// The logger.
public GuardHostedService(
ILibraryManager libraryManager,
IdentityResolver resolver,
GuardProcessor processor,
LibraryItemDateWriter writer,
LedgerStore store,
LedgerSynchronizer synchronizer,
ILogger logger)
{
_libraryManager = libraryManager;
_resolver = resolver;
_processor = processor;
_writer = writer;
_store = store;
_synchronizer = synchronizer;
_logger = logger;
}
///
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;
}
///
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");
}
}
///
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(), 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();
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
///
/// A queued library event. is set for removals.
///
private sealed record GuardEvent(Guid ItemId, BaseItem? RemovedItem);
}