Closes the two F9 Libraries.razor parity gaps and normalizes scan-show error
mapping to ProblemDetails.
TASK 1 — library-wide deep scan:
- QueueLibraryScanByLibraryId gains optional `bool DeepScan = false`; handler
threads it into ForceSynchronize{Plex,Jellyfin,Emby}LibraryById.
- POST /api/libraries/{id}/scan?deep=false binds it via [FromQuery].
TASK 2 — external-collections scan (new endpoints):
- POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=false
acquires the per-source collections lock (§3b: lock IS the running scan → 409),
enqueues Synchronize{X}Collections(id, ForceScan:true, deep) to the scanner
channel, returns 202; compensating-unlock on enqueue throw.
TASK 3 — scan-show normalization:
- New QueueShowScanResult enum; handler returns it instead of bool.
- POST /api/libraries/{id}/scan-show now maps 202/404/409/422 (all errors
ProblemDetails) instead of 200/404/400-anonymous-object.
- Updated the lone Blazor caller (TelevisionSeasonList.razor).
Tests: LibrariesController (scan deep=true, scan-show enum→status), the three
media-source controllers (scan-collections route/404/409/202/compensating-unlock),
and handler tests for both changed handlers (deep threading + show-scan outcomes).
Docs: api-conventions §3b exemplar + blazor-route-parity §5 F9 gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
253 lines
9.7 KiB
C#
253 lines
9.7 KiB
C#
using System.Threading.Channels;
|
|
using ErsatzTV.Application;
|
|
using ErsatzTV.Application.Libraries;
|
|
using ErsatzTV.Application.MediaSources;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Locking;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Tests.Support;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using ThreadingChannel = System.Threading.Channels.Channel;
|
|
|
|
namespace ErsatzTV.Tests.Application.Libraries;
|
|
|
|
[TestFixture]
|
|
public class QueueLibraryScanByLibraryIdHandlerTests
|
|
{
|
|
private InMemoryTvContext _db = null!;
|
|
|
|
[SetUp]
|
|
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
|
|
|
[TearDown]
|
|
public async Task TearDown() => await _db.DisposeAsync();
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Return_NotFound_When_Library_Missing()
|
|
{
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
|
|
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
|
|
|
QueueLibraryScanResult result = await handler.Handle(
|
|
new QueueLibraryScanByLibraryId(9999),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(QueueLibraryScanResult.NotFound);
|
|
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Return_SyncDisabled_When_Item_Sync_Off()
|
|
{
|
|
int libraryId = await SeedSyncDisabledPlexLibrary();
|
|
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
|
|
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
|
|
|
QueueLibraryScanResult result = await handler.Handle(
|
|
new QueueLibraryScanByLibraryId(libraryId),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(QueueLibraryScanResult.SyncDisabled);
|
|
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Queue_And_Enqueue_When_Lock_Acquired()
|
|
{
|
|
int libraryId = await SeedLocalLibrary();
|
|
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(true);
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
|
|
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
|
|
|
QueueLibraryScanResult result = await handler.Handle(
|
|
new QueueLibraryScanByLibraryId(libraryId),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(QueueLibraryScanResult.Queued);
|
|
locker.Received(1).LockLibrary(libraryId);
|
|
locker.DidNotReceive().UnlockLibrary(Arg.Any<int>());
|
|
channel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue();
|
|
request.ShouldBeOfType<ForceScanLocalLibrary>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Return_AlreadyScanning_When_Lock_Not_Acquired()
|
|
{
|
|
int libraryId = await SeedLocalLibrary();
|
|
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(false);
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
|
|
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
|
|
|
QueueLibraryScanResult result = await handler.Handle(
|
|
new QueueLibraryScanByLibraryId(libraryId),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(QueueLibraryScanResult.AlreadyScanning);
|
|
channel.Reader.TryRead(out _).ShouldBeFalse();
|
|
locker.DidNotReceive().UnlockLibrary(Arg.Any<int>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Two_Concurrent_Scans_Should_Be_Queued_Then_AlreadyScanning()
|
|
{
|
|
int libraryId = await SeedLocalLibrary();
|
|
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
// first acquire wins, second loses (a scan is already in progress)
|
|
locker.LockLibrary(libraryId).Returns(true, false);
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
|
|
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
|
|
|
QueueLibraryScanResult first = await handler.Handle(
|
|
new QueueLibraryScanByLibraryId(libraryId),
|
|
CancellationToken.None);
|
|
QueueLibraryScanResult second = await handler.Handle(
|
|
new QueueLibraryScanByLibraryId(libraryId),
|
|
CancellationToken.None);
|
|
|
|
first.ShouldBe(QueueLibraryScanResult.Queued);
|
|
second.ShouldBe(QueueLibraryScanResult.AlreadyScanning);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Unlock_And_Rethrow_When_Enqueue_Throws()
|
|
{
|
|
int libraryId = await SeedLocalLibrary();
|
|
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(true);
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
// a completed writer makes WriteAsync throw ChannelClosedException, simulating a failed enqueue
|
|
channel.Writer.Complete();
|
|
|
|
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
|
|
|
await Should.ThrowAsync<ChannelClosedException>(() =>
|
|
handler.Handle(new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None));
|
|
|
|
locker.Received(1).UnlockLibrary(libraryId);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Unlock_When_Enqueue_Canceled()
|
|
{
|
|
int libraryId = await SeedLocalLibrary();
|
|
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(true);
|
|
|
|
// a writer whose enqueue is aborted (e.g. request cancelled after the lock was taken)
|
|
var writer = Substitute.For<ChannelWriter<IScannerBackgroundServiceRequest>>();
|
|
writer.WriteAsync(Arg.Any<IScannerBackgroundServiceRequest>(), Arg.Any<CancellationToken>())
|
|
.Returns(ValueTask.FromException(new OperationCanceledException()));
|
|
|
|
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, writer);
|
|
|
|
await Should.ThrowAsync<OperationCanceledException>(() =>
|
|
handler.Handle(new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None));
|
|
|
|
locker.Received(1).UnlockLibrary(libraryId);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Thread_DeepScan_Into_Plex_ForceSynchronize()
|
|
{
|
|
int libraryId = await SeedSyncEnabledPlexLibrary();
|
|
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(true);
|
|
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
|
|
|
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
|
|
|
QueueLibraryScanResult result = await handler.Handle(
|
|
new QueueLibraryScanByLibraryId(libraryId, DeepScan: true),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(QueueLibraryScanResult.Queued);
|
|
|
|
// first message refreshes the library list, second is the deep force-sync carrying DeepScan == true
|
|
channel.Reader.TryRead(out IScannerBackgroundServiceRequest? first).ShouldBeTrue();
|
|
first.ShouldBeOfType<ErsatzTV.Application.Plex.SynchronizePlexLibraries>();
|
|
channel.Reader.TryRead(out IScannerBackgroundServiceRequest? second).ShouldBeTrue();
|
|
second.ShouldBeOfType<ErsatzTV.Application.Plex.ForceSynchronizePlexLibraryById>().DeepScan.ShouldBeTrue();
|
|
}
|
|
|
|
private QueueLibraryScanByLibraryIdHandler CreateHandler(
|
|
IEntityLocker locker,
|
|
ChannelWriter<IScannerBackgroundServiceRequest> writer) =>
|
|
new(
|
|
_db.Factory,
|
|
locker,
|
|
writer,
|
|
NullLogger<QueueLibraryScanByLibraryIdHandler>.Instance);
|
|
|
|
private async Task<int> SeedLocalLibrary()
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
var source = new LocalMediaSource
|
|
{
|
|
Libraries =
|
|
[
|
|
new LocalLibrary
|
|
{
|
|
Name = "Local Movies",
|
|
MediaKind = LibraryMediaKind.Movies,
|
|
Paths = []
|
|
}
|
|
]
|
|
};
|
|
await context.LocalMediaSources.AddAsync(source);
|
|
await context.SaveChangesAsync();
|
|
return source.Libraries[0].Id;
|
|
}
|
|
|
|
private async Task<int> SeedSyncDisabledPlexLibrary() => await SeedPlexLibrary(shouldSyncItems: false);
|
|
|
|
private async Task<int> SeedSyncEnabledPlexLibrary() => await SeedPlexLibrary(shouldSyncItems: true);
|
|
|
|
private async Task<int> SeedPlexLibrary(bool shouldSyncItems)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
var source = new PlexMediaSource
|
|
{
|
|
ServerName = "Plex Server",
|
|
ProductVersion = "1",
|
|
Platform = "Linux",
|
|
PlatformVersion = "1",
|
|
ClientIdentifier = "plex",
|
|
Connections = [],
|
|
PathReplacements = [],
|
|
Libraries =
|
|
[
|
|
new PlexLibrary
|
|
{
|
|
Name = "Plex Movies",
|
|
MediaKind = LibraryMediaKind.Movies,
|
|
Key = "1",
|
|
ShouldSyncItems = shouldSyncItems,
|
|
Paths = []
|
|
}
|
|
]
|
|
};
|
|
await context.PlexMediaSources.AddAsync(source);
|
|
await context.SaveChangesAsync();
|
|
return source.Libraries[0].Id;
|
|
}
|
|
}
|