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>
215 lines
7.5 KiB
C#
215 lines
7.5 KiB
C#
using System.Threading.Channels;
|
|
using ErsatzTV.Application;
|
|
using ErsatzTV.Application.Libraries;
|
|
using ErsatzTV.Application.Plex;
|
|
using ErsatzTV.Application.Subtitles;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Locking;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Tests.Support;
|
|
using LanguageExt;
|
|
using MediatR;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using static LanguageExt.Prelude;
|
|
using ThreadingChannel = System.Threading.Channels.Channel;
|
|
|
|
namespace ErsatzTV.Tests.Application.Libraries;
|
|
|
|
[TestFixture]
|
|
public class QueueShowScanByLibraryIdHandlerTests
|
|
{
|
|
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>();
|
|
var mediator = Substitute.For<IMediator>();
|
|
|
|
QueueShowScanByLibraryIdHandler handler = CreateHandler(locker, mediator, out _);
|
|
|
|
QueueShowScanResult result = await handler.Handle(
|
|
new QueueShowScanByLibraryId(9999, 1, "Show", false),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(QueueShowScanResult.NotFound);
|
|
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Return_SyncDisabled_When_Item_Sync_Off()
|
|
{
|
|
int libraryId = await SeedPlexLibrary(shouldSyncItems: false);
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
var mediator = Substitute.For<IMediator>();
|
|
|
|
QueueShowScanByLibraryIdHandler handler = CreateHandler(locker, mediator, out _);
|
|
|
|
QueueShowScanResult result = await handler.Handle(
|
|
new QueueShowScanByLibraryId(libraryId, 1, "Show", false),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(QueueShowScanResult.SyncDisabled);
|
|
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Return_AlreadyScanning_When_Lock_Not_Acquired()
|
|
{
|
|
int libraryId = await SeedPlexLibrary(shouldSyncItems: true);
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(false);
|
|
var mediator = Substitute.For<IMediator>();
|
|
|
|
QueueShowScanByLibraryIdHandler handler = CreateHandler(locker, mediator, out _);
|
|
|
|
QueueShowScanResult result = await handler.Handle(
|
|
new QueueShowScanByLibraryId(libraryId, 1, "Show", false),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(QueueShowScanResult.AlreadyScanning);
|
|
await mediator.DidNotReceive().Send(Arg.Any<SynchronizePlexShowById>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Return_Unsupported_For_Local_Library()
|
|
{
|
|
int libraryId = await SeedLocalLibrary();
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(true);
|
|
var mediator = Substitute.For<IMediator>();
|
|
|
|
QueueShowScanByLibraryIdHandler handler = CreateHandler(locker, mediator, out _);
|
|
|
|
QueueShowScanResult result = await handler.Handle(
|
|
new QueueShowScanByLibraryId(libraryId, 1, "Show", false),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(QueueShowScanResult.Unsupported);
|
|
locker.Received(1).UnlockLibrary(libraryId);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Return_Queued_And_Extract_Subtitles_On_Deep_Success()
|
|
{
|
|
int libraryId = await SeedPlexLibrary(shouldSyncItems: true);
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(true);
|
|
var mediator = Substitute.For<IMediator>();
|
|
mediator.Send(Arg.Any<SynchronizePlexShowById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, string>("ok"));
|
|
|
|
QueueShowScanByLibraryIdHandler handler = CreateHandler(
|
|
locker,
|
|
mediator,
|
|
out Channel<IBackgroundServiceRequest> worker);
|
|
|
|
QueueShowScanResult result = await handler.Handle(
|
|
new QueueShowScanByLibraryId(libraryId, 42, "Show", DeepScan: true),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(QueueShowScanResult.Queued);
|
|
locker.Received(1).UnlockLibrary(libraryId);
|
|
worker.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
|
request.ShouldBeOfType<ExtractEmbeddedShowSubtitles>().ShowId.ShouldBe(42);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Return_ScanFailed_When_SubScan_Left()
|
|
{
|
|
int libraryId = await SeedPlexLibrary(shouldSyncItems: true);
|
|
var locker = Substitute.For<IEntityLocker>();
|
|
locker.LockLibrary(libraryId).Returns(true);
|
|
var mediator = Substitute.For<IMediator>();
|
|
mediator.Send(Arg.Any<SynchronizePlexShowById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, string>(BaseError.New("scan error")));
|
|
|
|
QueueShowScanByLibraryIdHandler handler = CreateHandler(
|
|
locker,
|
|
mediator,
|
|
out Channel<IBackgroundServiceRequest> worker);
|
|
|
|
QueueShowScanResult result = await handler.Handle(
|
|
new QueueShowScanByLibraryId(libraryId, 42, "Show", DeepScan: true),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(QueueShowScanResult.ScanFailed);
|
|
locker.Received(1).UnlockLibrary(libraryId);
|
|
// no subtitle extraction on a failed scan
|
|
worker.Reader.TryRead(out _).ShouldBeFalse();
|
|
}
|
|
|
|
private QueueShowScanByLibraryIdHandler CreateHandler(
|
|
IEntityLocker locker,
|
|
IMediator mediator,
|
|
out Channel<IBackgroundServiceRequest> worker)
|
|
{
|
|
worker = ThreadingChannel.CreateUnbounded<IBackgroundServiceRequest>();
|
|
return new QueueShowScanByLibraryIdHandler(
|
|
_db.Factory,
|
|
locker,
|
|
mediator,
|
|
worker.Writer,
|
|
NullLogger<QueueShowScanByLibraryIdHandler>.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> 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 Shows",
|
|
MediaKind = LibraryMediaKind.Shows,
|
|
Key = "1",
|
|
ShouldSyncItems = shouldSyncItems,
|
|
Paths = []
|
|
}
|
|
]
|
|
};
|
|
await context.PlexMediaSources.AddAsync(source);
|
|
await context.SaveChangesAsync();
|
|
return source.Libraries[0].Id;
|
|
}
|
|
}
|