Scan queue handler now returns a QueueLibraryScanResult enum (Queued|NotFound|SyncDisabled|AlreadyScanning) instead of a lying bool; LibrariesController.ScanLibrary maps them to 202/404/422/409 with ProblemDetails. Guard the lock->enqueue with the EnqueueWithTraktLock compensating-unlock pattern. ScannerService now releases every library/collection lock in a finally so a handler exception can't leak the lock. Plex "Shows" scheduler batch (one lock, two messages) now has only the trailing SynchronizePlexNetworks carry the single release (Unlock flag), mirroring the scheduler Trakt tail-token precedent. Guard the other lock->enqueue producers (Create/UpdateLocalLibrary, UpdateTraktList) with compensating unlock. SPA drops the PENDING_GRACE_TICKS heuristic now that the POST reports 202/409/404/422 directly: 202 -> pending+poll, 409 -> reconcile (no error toast), 404/422 -> surface error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
448 lines
18 KiB
C#
448 lines
18 KiB
C#
using System.Threading.Channels;
|
|
using ErsatzTV.Application;
|
|
using ErsatzTV.Application.Emby;
|
|
using ErsatzTV.Application.Jellyfin;
|
|
using ErsatzTV.Application.MediaSources;
|
|
using ErsatzTV.Application.Plex;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Errors;
|
|
using ErsatzTV.Core.Interfaces.Locking;
|
|
using MediatR;
|
|
|
|
namespace ErsatzTV.Services;
|
|
|
|
public class ScannerService : BackgroundService
|
|
{
|
|
private readonly ChannelReader<IScannerBackgroundServiceRequest> _channel;
|
|
private readonly ILogger<ScannerService> _logger;
|
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
|
private readonly SystemStartup _systemStartup;
|
|
|
|
public ScannerService(
|
|
ChannelReader<IScannerBackgroundServiceRequest> channel,
|
|
IServiceScopeFactory serviceScopeFactory,
|
|
SystemStartup systemStartup,
|
|
ILogger<ScannerService> logger)
|
|
{
|
|
_channel = channel;
|
|
_serviceScopeFactory = serviceScopeFactory;
|
|
_systemStartup = systemStartup;
|
|
_logger = logger;
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
await Task.Yield();
|
|
|
|
await _systemStartup.WaitForDatabase(stoppingToken);
|
|
await _systemStartup.WaitForSearchIndex(stoppingToken);
|
|
try
|
|
{
|
|
_logger.LogInformation("Scanner service started");
|
|
|
|
await foreach (IScannerBackgroundServiceRequest request in _channel.ReadAllAsync(stoppingToken))
|
|
{
|
|
try
|
|
{
|
|
Task requestTask;
|
|
switch (request)
|
|
{
|
|
case SynchronizePlexLibraries synchronizePlexLibraries:
|
|
requestTask = SynchronizeLibraries(synchronizePlexLibraries, stoppingToken);
|
|
break;
|
|
case ISynchronizePlexLibraryById synchronizePlexLibraryById:
|
|
requestTask = SynchronizePlexLibrary(synchronizePlexLibraryById, stoppingToken);
|
|
break;
|
|
case SynchronizePlexCollections synchronizePlexCollections:
|
|
requestTask = SynchronizePlexCollections(synchronizePlexCollections, stoppingToken);
|
|
break;
|
|
case SynchronizePlexNetworks synchronizePlexNetworks:
|
|
requestTask = SynchronizePlexNetworks(synchronizePlexNetworks, stoppingToken);
|
|
break;
|
|
case SynchronizeJellyfinLibraries synchronizeJellyfinLibraries:
|
|
requestTask = SynchronizeLibraries(synchronizeJellyfinLibraries, stoppingToken);
|
|
break;
|
|
case ISynchronizeJellyfinLibraryById synchronizeJellyfinLibraryById:
|
|
requestTask = SynchronizeJellyfinLibrary(synchronizeJellyfinLibraryById, stoppingToken);
|
|
break;
|
|
case SynchronizeJellyfinCollections synchronizeJellyfinCollections:
|
|
requestTask = SynchronizeJellyfinCollections(synchronizeJellyfinCollections, stoppingToken);
|
|
break;
|
|
case SynchronizeEmbyLibraries synchronizeEmbyLibraries:
|
|
requestTask = SynchronizeLibraries(synchronizeEmbyLibraries, stoppingToken);
|
|
break;
|
|
case ISynchronizeEmbyLibraryById synchronizeEmbyLibraryById:
|
|
requestTask = SynchronizeEmbyLibrary(synchronizeEmbyLibraryById, stoppingToken);
|
|
break;
|
|
case SynchronizeEmbyCollections synchronizeEmbyCollections:
|
|
requestTask = SynchronizeEmbyCollections(synchronizeEmbyCollections, stoppingToken);
|
|
break;
|
|
case IScanLocalLibrary scanLocalLibrary:
|
|
requestTask = SynchronizeLocalLibrary(scanLocalLibrary, stoppingToken);
|
|
break;
|
|
default:
|
|
throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}");
|
|
}
|
|
|
|
await requestTask;
|
|
}
|
|
catch (Exception ex) when (ex is not (TaskCanceledException or OperationCanceledException))
|
|
{
|
|
_logger.LogWarning(ex, "Failed to process scanner background service request");
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
|
{
|
|
_logger.LogInformation("Scanner service shutting down");
|
|
}
|
|
}
|
|
|
|
private async Task SynchronizeLocalLibrary(IScanLocalLibrary request, CancellationToken cancellationToken)
|
|
{
|
|
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
|
|
|
try
|
|
{
|
|
Either<BaseError, string> scanResult = await mediator.Send(request, cancellationToken);
|
|
scanResult.BiIter(
|
|
name => _logger.LogDebug(
|
|
"Done scanning local library {Library}",
|
|
name),
|
|
error =>
|
|
{
|
|
if (error is ScanIsNotRequired)
|
|
{
|
|
_logger.LogDebug(
|
|
"Scan is not required for local library {LibraryId} at this time",
|
|
request.LibraryId);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(
|
|
"Unable to scan local library {LibraryId}: {Error}",
|
|
request.LibraryId,
|
|
error.Value);
|
|
}
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
if (entityLocker.IsLibraryLocked(request.LibraryId))
|
|
{
|
|
entityLocker.UnlockLibrary(request.LibraryId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SynchronizeLibraries(SynchronizePlexLibraries request, CancellationToken cancellationToken)
|
|
{
|
|
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
|
|
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
|
result.BiIter(
|
|
_ => _logger.LogInformation(
|
|
"Successfully synchronized plex libraries for source {MediaSourceId}",
|
|
request.PlexMediaSourceId),
|
|
error => _logger.LogWarning(
|
|
"Unable to synchronize plex libraries for source {MediaSourceId}: {Error}",
|
|
request.PlexMediaSourceId,
|
|
error.Value));
|
|
}
|
|
|
|
private async Task SynchronizePlexLibrary(
|
|
ISynchronizePlexLibraryById request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
|
|
|
try
|
|
{
|
|
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
|
result.BiIter(
|
|
name => _logger.LogDebug("Done synchronizing plex library {Name}", name),
|
|
error =>
|
|
{
|
|
if (error is ScanIsNotRequired)
|
|
{
|
|
_logger.LogDebug(
|
|
"Scan is not required for plex library {LibraryId} at this time",
|
|
request.PlexLibraryId);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(
|
|
"Unable to synchronize plex library {LibraryId}: {Error}",
|
|
request.PlexLibraryId,
|
|
error.Value);
|
|
}
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
// request.Unlock is false when a later message in the same batch (SynchronizePlexNetworks)
|
|
// owns the single release for this library lock.
|
|
if (request.Unlock && entityLocker.IsLibraryLocked(request.PlexLibraryId))
|
|
{
|
|
entityLocker.UnlockLibrary(request.PlexLibraryId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SynchronizePlexCollections(
|
|
SynchronizePlexCollections request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
|
|
|
try
|
|
{
|
|
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
|
result.BiIter(
|
|
_ => _logger.LogDebug("Done synchronizing plex collections"),
|
|
error =>
|
|
{
|
|
if (error is ScanIsNotRequired)
|
|
{
|
|
_logger.LogDebug("Scan is not required for plex collections at this time");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Unable to synchronize plex collections: {Error}", error.Value);
|
|
}
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
if (entityLocker.ArePlexCollectionsLocked())
|
|
{
|
|
entityLocker.UnlockPlexCollections();
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SynchronizePlexNetworks(
|
|
SynchronizePlexNetworks request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
|
|
|
try
|
|
{
|
|
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
|
result.BiIter(
|
|
_ => _logger.LogDebug(
|
|
"Done synchronizing plex networks for library {LibraryId}",
|
|
request.PlexLibraryId),
|
|
error =>
|
|
{
|
|
if (error is ScanIsNotRequired)
|
|
{
|
|
_logger.LogDebug(
|
|
"Scan is not required for plex networks in library {LibraryId} at this time",
|
|
request.PlexLibraryId);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(
|
|
"Unable to synchronize plex networks for library {LibraryId}: {Error}",
|
|
request.PlexLibraryId,
|
|
error.Value);
|
|
}
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
if (request.Unlock && entityLocker.IsLibraryLocked(request.PlexLibraryId))
|
|
{
|
|
entityLocker.UnlockLibrary(request.PlexLibraryId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SynchronizeLibraries(SynchronizeJellyfinLibraries request, CancellationToken cancellationToken)
|
|
{
|
|
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
|
|
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
|
result.BiIter(
|
|
_ => _logger.LogInformation(
|
|
"Successfully synchronized Jellyfin libraries for source {MediaSourceId}",
|
|
request.JellyfinMediaSourceId),
|
|
error => _logger.LogWarning(
|
|
"Unable to synchronize Jellyfin libraries for source {MediaSourceId}: {Error}",
|
|
request.JellyfinMediaSourceId,
|
|
error.Value));
|
|
}
|
|
|
|
private async Task SynchronizeJellyfinLibrary(
|
|
ISynchronizeJellyfinLibraryById request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
|
|
|
try
|
|
{
|
|
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
|
result.BiIter(
|
|
name => _logger.LogDebug("Done synchronizing jellyfin library {Name}", name),
|
|
error =>
|
|
{
|
|
if (error is ScanIsNotRequired)
|
|
{
|
|
_logger.LogDebug(
|
|
"Scan is not required for jellyfin library {LibraryId} at this time",
|
|
request.JellyfinLibraryId);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(
|
|
"Unable to synchronize jellyfin library {LibraryId}: {Error}",
|
|
request.JellyfinLibraryId,
|
|
error.Value);
|
|
}
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
if (entityLocker.IsLibraryLocked(request.JellyfinLibraryId))
|
|
{
|
|
entityLocker.UnlockLibrary(request.JellyfinLibraryId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SynchronizeJellyfinCollections(
|
|
SynchronizeJellyfinCollections request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
|
|
|
try
|
|
{
|
|
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
|
result.BiIter(
|
|
_ => _logger.LogDebug("Done synchronizing jellyfin collections"),
|
|
error =>
|
|
{
|
|
if (error is ScanIsNotRequired)
|
|
{
|
|
_logger.LogDebug("Scan is not required for jellyfin collections at this time");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Unable to synchronize jellyfin collections: {Error}", error.Value);
|
|
}
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
if (entityLocker.AreJellyfinCollectionsLocked())
|
|
{
|
|
entityLocker.UnlockJellyfinCollections();
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SynchronizeLibraries(SynchronizeEmbyLibraries request, CancellationToken cancellationToken)
|
|
{
|
|
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
|
|
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
|
result.BiIter(
|
|
_ => _logger.LogInformation(
|
|
"Successfully synchronized Emby libraries for source {MediaSourceId}",
|
|
request.EmbyMediaSourceId),
|
|
error => _logger.LogWarning(
|
|
"Unable to synchronize Emby libraries for source {MediaSourceId}: {Error}",
|
|
request.EmbyMediaSourceId,
|
|
error.Value));
|
|
}
|
|
|
|
private async Task SynchronizeEmbyLibrary(ISynchronizeEmbyLibraryById request, CancellationToken cancellationToken)
|
|
{
|
|
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
|
|
|
try
|
|
{
|
|
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
|
result.BiIter(
|
|
name => _logger.LogDebug("Done synchronizing emby library {Name}", name),
|
|
error =>
|
|
{
|
|
if (error is ScanIsNotRequired)
|
|
{
|
|
_logger.LogDebug(
|
|
"Scan is not required for emby library {LibraryId} at this time",
|
|
request.EmbyLibraryId);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(
|
|
"Unable to synchronize emby library {LibraryId}: {Error}",
|
|
request.EmbyLibraryId,
|
|
error.Value);
|
|
}
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
if (entityLocker.IsLibraryLocked(request.EmbyLibraryId))
|
|
{
|
|
entityLocker.UnlockLibrary(request.EmbyLibraryId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SynchronizeEmbyCollections(
|
|
SynchronizeEmbyCollections request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
|
|
|
try
|
|
{
|
|
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
|
result.BiIter(
|
|
_ => _logger.LogDebug("Done synchronizing emby collections"),
|
|
error =>
|
|
{
|
|
if (error is ScanIsNotRequired)
|
|
{
|
|
_logger.LogDebug("Scan is not required for emby collections at this time");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Unable to synchronize emby collections: {Error}", error.Value);
|
|
}
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
if (entityLocker.AreEmbyCollectionsLocked())
|
|
{
|
|
entityLocker.UnlockEmbyCollections();
|
|
}
|
|
}
|
|
}
|
|
}
|