feat(235): F9 API parity — library deep-scan, external-collections scan, scan-show outcome enum (#235 slice B)

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>
This commit is contained in:
2026-07-11 18:02:17 +02:00
co-authored by Claude Opus 4.8
parent 8e2f995492
commit 628c9d7228
17 changed files with 704 additions and 42 deletions
@@ -8,4 +8,4 @@ public enum QueueLibraryScanResult
AlreadyScanning
}
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<QueueLibraryScanResult>;
public record QueueLibraryScanByLibraryId(int LibraryId, bool DeepScan = false) : IRequest<QueueLibraryScanResult>;
@@ -66,7 +66,7 @@ public class QueueLibraryScanByLibraryIdHandler(
new SynchronizePlexLibraries(library.MediaSourceId),
cancellationToken);
await scannerWorker.WriteAsync(
new ForceSynchronizePlexLibraryById(library.Id, false),
new ForceSynchronizePlexLibraryById(library.Id, request.DeepScan),
cancellationToken);
break;
case JellyfinLibrary:
@@ -74,7 +74,7 @@ public class QueueLibraryScanByLibraryIdHandler(
new SynchronizeJellyfinLibraries(library.MediaSourceId),
cancellationToken);
await scannerWorker.WriteAsync(
new ForceSynchronizeJellyfinLibraryById(library.Id, false),
new ForceSynchronizeJellyfinLibraryById(library.Id, request.DeepScan),
cancellationToken);
break;
case EmbyLibrary:
@@ -82,7 +82,7 @@ public class QueueLibraryScanByLibraryIdHandler(
new SynchronizeEmbyLibraries(library.MediaSourceId),
cancellationToken);
await scannerWorker.WriteAsync(
new ForceSynchronizeEmbyLibraryById(library.Id, false),
new ForceSynchronizeEmbyLibraryById(library.Id, request.DeepScan),
cancellationToken);
break;
}
@@ -1,3 +1,14 @@
namespace ErsatzTV.Application.Libraries;
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan) : IRequest<bool>;
public enum QueueShowScanResult
{
Queued,
NotFound,
Unsupported,
SyncDisabled,
AlreadyScanning,
ScanFailed
}
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan)
: IRequest<QueueShowScanResult>;
@@ -19,9 +19,9 @@ public class QueueShowScanByLibraryIdHandler(
IMediator mediator,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<QueueShowScanByLibraryIdHandler> logger)
: IRequestHandler<QueueShowScanByLibraryId, bool>
: IRequestHandler<QueueShowScanByLibraryId, QueueShowScanResult>
{
public async Task<bool> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken)
public async Task<QueueShowScanResult> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -42,14 +42,14 @@ public class QueueShowScanByLibraryIdHandler(
if (!shouldSyncItems)
{
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id);
return false;
return QueueShowScanResult.SyncDisabled;
}
// Check if library is already being scanned - return false if locked
// A false from LockLibrary means a scan is already in progress; we own no release.
if (!locker.LockLibrary(library.Id))
{
logger.LogWarning("Library {Id} is already being scanned, cannot scan individual show", library.Id);
return false;
return QueueShowScanResult.AlreadyScanning;
}
logger.LogDebug(
@@ -60,41 +60,43 @@ public class QueueShowScanByLibraryIdHandler(
try
{
var success = false;
QueueShowScanResult outcome;
switch (library)
{
case PlexLibrary:
Either<BaseError, string> plexResult = await mediator.Send(
new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken);
success = plexResult.IsRight;
outcome = plexResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
break;
case JellyfinLibrary:
Either<BaseError, string> jellyfinResult = await mediator.Send(
new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken);
success = jellyfinResult.IsRight;
outcome = jellyfinResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
break;
case EmbyLibrary:
Either<BaseError, string> embyResult = await mediator.Send(
new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken);
success = embyResult.IsRight;
outcome = embyResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
break;
case LocalLibrary:
logger.LogWarning("Single show scanning is not supported for local libraries");
outcome = QueueShowScanResult.Unsupported;
break;
default:
logger.LogWarning("Unknown library type for library {Id}", library.Id);
outcome = QueueShowScanResult.Unsupported;
break;
}
if (success && request.DeepScan)
if (outcome == QueueShowScanResult.Queued && request.DeepScan)
{
await workerChannel.WriteAsync(new ExtractEmbeddedShowSubtitles(request.ShowId), cancellationToken);
}
return success;
return outcome;
}
finally
{
@@ -103,6 +105,6 @@ public class QueueShowScanByLibraryIdHandler(
}
}
return false;
return QueueShowScanResult.NotFound;
}
}
@@ -164,6 +164,30 @@ public class QueueLibraryScanByLibraryIdHandlerTests
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) =>
@@ -193,7 +217,11 @@ public class QueueLibraryScanByLibraryIdHandlerTests
return source.Libraries[0].Id;
}
private async Task<int> SeedSyncDisabledPlexLibrary()
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
@@ -212,7 +240,7 @@ public class QueueLibraryScanByLibraryIdHandlerTests
Name = "Plex Movies",
MediaKind = LibraryMediaKind.Movies,
Key = "1",
ShouldSyncItems = false,
ShouldSyncItems = shouldSyncItems,
Paths = []
}
]
@@ -0,0 +1,214 @@
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;
}
}
@@ -75,6 +75,10 @@ public class EmbyMediaSourcesControllerTests
nameof(EmbyMediaSourcesController.RefreshLibraries),
"POST",
"/api/media-sources/emby/{id:int}/refresh-libraries");
ShouldHaveActionRoute(
nameof(EmbyMediaSourcesController.ScanCollections),
"POST",
"/api/media-sources/emby/{id:int}/scan-collections");
}
[Test]
@@ -414,6 +418,48 @@ public class EmbyMediaSourcesControllerTests
request.ShouldBeOfType<SynchronizeEmbyLibraries>().EmbyMediaSourceId.ShouldBe(1);
}
[Test]
public async Task ScanCollections_Should_Return_404_When_Source_Missing()
{
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
.Returns(Option<EmbyMediaSourceViewModel>.None);
IActionResult result = await _controller.ScanCollections(99, cancellationToken: CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
_entityLocker.DidNotReceive().LockEmbyCollections();
}
[Test]
public async Task ScanCollections_Should_Return_409_When_Collections_Locked()
{
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
.Returns(Option<EmbyMediaSourceViewModel>.Some(new EmbyMediaSourceViewModel(1, "s", "a")));
_entityLocker.LockEmbyCollections().Returns(false);
IActionResult result = await _controller.ScanCollections(1, cancellationToken: CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
_scannerChannel.Reader.TryRead(out _).ShouldBeFalse();
}
[Test]
public async Task ScanCollections_Should_Enqueue_And_Return_202()
{
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
.Returns(Option<EmbyMediaSourceViewModel>.Some(new EmbyMediaSourceViewModel(1, "s", "a")));
_entityLocker.LockEmbyCollections().Returns(true);
IActionResult result = await _controller.ScanCollections(1, deep: true, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>();
_scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue();
var command = request.ShouldBeOfType<SynchronizeEmbyCollections>();
command.EmbyMediaSourceId.ShouldBe(1);
command.ForceScan.ShouldBeTrue();
command.DeepScan.ShouldBeTrue();
}
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(EmbyMediaSourcesController).GetMethod(actionName)
@@ -75,6 +75,10 @@ public class JellyfinMediaSourcesControllerTests
nameof(JellyfinMediaSourcesController.RefreshLibraries),
"POST",
"/api/media-sources/jellyfin/{id:int}/refresh-libraries");
ShouldHaveActionRoute(
nameof(JellyfinMediaSourcesController.ScanCollections),
"POST",
"/api/media-sources/jellyfin/{id:int}/scan-collections");
}
[Test]
@@ -414,6 +418,48 @@ public class JellyfinMediaSourcesControllerTests
request.ShouldBeOfType<SynchronizeJellyfinLibraries>().JellyfinMediaSourceId.ShouldBe(1);
}
[Test]
public async Task ScanCollections_Should_Return_404_When_Source_Missing()
{
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
.Returns(Option<JellyfinMediaSourceViewModel>.None);
IActionResult result = await _controller.ScanCollections(99, cancellationToken: CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
_entityLocker.DidNotReceive().LockJellyfinCollections();
}
[Test]
public async Task ScanCollections_Should_Return_409_When_Collections_Locked()
{
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
.Returns(Option<JellyfinMediaSourceViewModel>.Some(new JellyfinMediaSourceViewModel(1, "s", "a")));
_entityLocker.LockJellyfinCollections().Returns(false);
IActionResult result = await _controller.ScanCollections(1, cancellationToken: CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
_scannerChannel.Reader.TryRead(out _).ShouldBeFalse();
}
[Test]
public async Task ScanCollections_Should_Enqueue_And_Return_202()
{
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
.Returns(Option<JellyfinMediaSourceViewModel>.Some(new JellyfinMediaSourceViewModel(1, "s", "a")));
_entityLocker.LockJellyfinCollections().Returns(true);
IActionResult result = await _controller.ScanCollections(1, deep: true, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>();
_scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue();
var command = request.ShouldBeOfType<SynchronizeJellyfinCollections>();
command.JellyfinMediaSourceId.ShouldBe(1);
command.ForceScan.ShouldBeTrue();
command.DeepScan.ShouldBeTrue();
}
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(JellyfinMediaSourcesController).GetMethod(actionName)
@@ -71,14 +71,15 @@ public class LibrariesControllerTests
}
[Test]
public async Task ScanShow_Should_Queue_Scan_By_Show_Id_When_Show_Belongs_To_Library()
public async Task ScanShow_Should_Return_202_And_Queue_Scan_By_Show_Id_When_Show_Belongs_To_Library()
{
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>()).Returns(true);
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueShowScanResult.Queued);
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42, DeepScan: true));
result.ShouldBeOfType<OkResult>();
result.ShouldBeOfType<AcceptedResult>();
await _mediator.Received(1).Send(
Arg.Is<QueueShowScanByLibraryId>(r =>
r.LibraryId == 3 && r.ShowId == 42 && r.ShowTitle == "The Office" && r.DeepScan),
@@ -86,14 +87,68 @@ public class LibrariesControllerTests
}
[Test]
public async Task ScanShow_Should_Return_BadRequest_When_Mediator_Fails_To_Queue()
public async Task ScanShow_Should_Return_409_When_AlreadyScanning()
{
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>()).Returns(false);
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueShowScanResult.AlreadyScanning);
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
result.ShouldBeOfType<BadRequestObjectResult>();
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status409Conflict);
}
[Test]
public async Task ScanShow_Should_Return_422_When_SyncDisabled()
{
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueShowScanResult.SyncDisabled);
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity);
}
[Test]
public async Task ScanShow_Should_Return_422_When_Unsupported()
{
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueShowScanResult.Unsupported);
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity);
}
[Test]
public async Task ScanShow_Should_Return_422_When_ScanFailed()
{
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueShowScanResult.ScanFailed);
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity);
}
[Test]
public async Task ScanShow_Should_Return_404_When_Handler_Reports_NotFound()
{
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueShowScanResult.NotFound);
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
}
[Test]
@@ -102,11 +157,25 @@ public class LibrariesControllerTests
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.Queued);
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
IActionResult result = await _controller.ScanLibrary(7, cancellationToken: CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>();
await _mediator.Received(1).Send(
Arg.Is<QueueLibraryScanByLibraryId>(r => r.LibraryId == 7),
Arg.Is<QueueLibraryScanByLibraryId>(r => r.LibraryId == 7 && !r.DeepScan),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ScanLibrary_Should_Pass_DeepScan_When_Deep_Query_True()
{
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.Queued);
IActionResult result = await _controller.ScanLibrary(7, deep: true, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>();
await _mediator.Received(1).Send(
Arg.Is<QueueLibraryScanByLibraryId>(r => r.LibraryId == 7 && r.DeepScan),
Arg.Any<CancellationToken>());
}
@@ -116,7 +185,7 @@ public class LibrariesControllerTests
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.NotFound);
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
IActionResult result = await _controller.ScanLibrary(7, cancellationToken: CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
@@ -128,7 +197,7 @@ public class LibrariesControllerTests
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.AlreadyScanning);
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
IActionResult result = await _controller.ScanLibrary(7, cancellationToken: CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status409Conflict);
@@ -140,7 +209,7 @@ public class LibrariesControllerTests
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.SyncDisabled);
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
IActionResult result = await _controller.ScanLibrary(7, cancellationToken: CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity);
@@ -70,6 +70,10 @@ public class PlexMediaSourcesControllerTests
nameof(PlexMediaSourcesController.RefreshLibraries),
"POST",
"/api/media-sources/plex/{id:int}/refresh-libraries");
ShouldHaveActionRoute(
nameof(PlexMediaSourcesController.ScanCollections),
"POST",
"/api/media-sources/plex/{id:int}/scan-collections");
}
// ----- P1 GetState -----
@@ -452,6 +456,64 @@ public class PlexMediaSourcesControllerTests
Arg.Any<CancellationToken>());
}
// ----- P9 ScanCollections -----
[Test]
public async Task ScanCollections_Should_Return_404_When_Source_Missing()
{
SourceExists(false);
IActionResult result = await _controller.ScanCollections(9, cancellationToken: CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
_entityLocker.DidNotReceive().LockPlexCollections();
await _channel.DidNotReceive().WriteAsync(
Arg.Any<IScannerBackgroundServiceRequest>(),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ScanCollections_Should_Return_409_When_Collections_Locked()
{
SourceExists(true);
_entityLocker.LockPlexCollections().Returns(false);
IActionResult result = await _controller.ScanCollections(3, cancellationToken: CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _channel.DidNotReceive().WriteAsync(
Arg.Any<IScannerBackgroundServiceRequest>(),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ScanCollections_Should_Return_202_And_Enqueue()
{
SourceExists(true);
_entityLocker.LockPlexCollections().Returns(true);
IActionResult result = await _controller.ScanCollections(3, deep: true, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>();
await _channel.Received(1).WriteAsync(
Arg.Is<SynchronizePlexCollections>(s => s.PlexMediaSourceId == 3 && s.ForceScan && s.DeepScan),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ScanCollections_Should_Compensate_Unlock_When_Enqueue_Throws()
{
SourceExists(true);
_entityLocker.LockPlexCollections().Returns(true);
_channel.WriteAsync(Arg.Any<SynchronizePlexCollections>(), Arg.Any<CancellationToken>())
.Returns<ValueTask>(_ => throw new InvalidOperationException("channel closed"));
await Should.ThrowAsync<InvalidOperationException>(
() => _controller.ScanCollections(3, cancellationToken: CancellationToken.None));
_entityLocker.Received(1).UnlockPlexCollections();
}
private void SourceExists(bool exists) =>
_mediator.Send(Arg.Any<GetPlexMediaSourceById>(), Arg.Any<CancellationToken>())
.Returns(exists
@@ -278,6 +278,53 @@ public class EmbyMediaSourcesController(
return new AcceptedResult();
}
[HttpPost("/api/media-sources/emby/{id:int}/scan-collections", Name = "ScanEmbyCollections")]
[Tags("Emby")]
[EndpointSummary("Scan an Emby source's collections")]
[EndpointDescription(
"Queues a synchronization of the source's collections (fire-and-forget). Pass ?deep=true for a deep " +
"scan. Returns 409 while an Emby collections scan is already in progress.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> ScanCollections(
int id,
[FromQuery] bool deep = false,
CancellationToken cancellationToken = default)
{
Option<EmbyMediaSourceViewModel> maybeSource =
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
if (maybeSource.IsNone)
{
return ApiResults.NotFoundProblem();
}
// The collections lock IS the running scan (§3b): fail to acquire = a scan is already active → 409.
if (!entityLocker.LockEmbyCollections())
{
return ApiResults.ConflictProblem(
"Emby collections scan in progress",
"An Emby collections scan is already in progress; try again once it completes.");
}
try
{
await scannerWorkerChannel.WriteAsync(
new SynchronizeEmbyCollections(id, true, deep),
cancellationToken);
}
catch
{
// the scanner releases the lock when it processes the message; if the enqueue throws after we
// acquired the lock, release it here (EnqueueWithTraktLock compensating-unlock, §3b)
entityLocker.UnlockEmbyCollections();
throw;
}
return new AcceptedResult();
}
// §C7: LockLibrary then enqueue the sync pair; a throw from the enqueue compensates by unlocking
// (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity).
private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken)
@@ -278,6 +278,53 @@ public class JellyfinMediaSourcesController(
return new AcceptedResult();
}
[HttpPost("/api/media-sources/jellyfin/{id:int}/scan-collections", Name = "ScanJellyfinCollections")]
[Tags("Jellyfin")]
[EndpointSummary("Scan a Jellyfin source's collections")]
[EndpointDescription(
"Queues a synchronization of the source's collections (fire-and-forget). Pass ?deep=true for a deep " +
"scan. Returns 409 while a Jellyfin collections scan is already in progress.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> ScanCollections(
int id,
[FromQuery] bool deep = false,
CancellationToken cancellationToken = default)
{
Option<JellyfinMediaSourceViewModel> maybeSource =
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
if (maybeSource.IsNone)
{
return ApiResults.NotFoundProblem();
}
// The collections lock IS the running scan (§3b): fail to acquire = a scan is already active → 409.
if (!entityLocker.LockJellyfinCollections())
{
return ApiResults.ConflictProblem(
"Jellyfin collections scan in progress",
"A Jellyfin collections scan is already in progress; try again once it completes.");
}
try
{
await scannerWorkerChannel.WriteAsync(
new SynchronizeJellyfinCollections(id, true, deep),
cancellationToken);
}
catch
{
// the scanner releases the lock when it processes the message; if the enqueue throws after we
// acquired the lock, release it here (EnqueueWithTraktLock compensating-unlock, §3b)
entityLocker.UnlockJellyfinCollections();
throw;
}
return new AcceptedResult();
}
// §C7: LockLibrary then enqueue the sync pair; a throw from the enqueue compensates by unlocking
// (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity).
private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken)
@@ -22,13 +22,18 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
[HttpPost("/api/libraries/{id:int}/scan")]
[Tags("Libraries")]
[EndpointSummary("Scan library")]
[EndpointDescription("Queues a scan of the whole library. Pass ?deep=true for a deep (metadata-refresh) scan.")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ScanLibrary(int id, CancellationToken cancellationToken)
public async Task<IActionResult> ScanLibrary(
int id,
[FromQuery] bool deep = false,
CancellationToken cancellationToken = default)
{
QueueLibraryScanResult result = await mediator.Send(new QueueLibraryScanByLibraryId(id), cancellationToken);
QueueLibraryScanResult result =
await mediator.Send(new QueueLibraryScanByLibraryId(id, deep), cancellationToken);
return result switch
{
QueueLibraryScanResult.Queued => new AcceptedResult(),
@@ -49,19 +54,47 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
[HttpPost("/api/libraries/{id:int}/scan-show")]
[Tags("Libraries")]
[EndpointSummary("Scan show")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ScanShow(int id, [FromBody] ScanShowRequest request)
{
Option<string> maybeTitle = await televisionRepository.GetShowTitle(id, request.ShowId);
foreach (string title in maybeTitle)
{
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, request.ShowId, title, request.DeepScan));
QueueShowScanResult result =
await mediator.Send(new QueueShowScanByLibraryId(id, request.ShowId, title, request.DeepScan));
return result
? new OkResult()
: new BadRequestObjectResult(new { error = "Unable to queue show scan. Library may not exist, may not support single show scanning, or may already be scanning." });
return result switch
{
QueueShowScanResult.Queued => new AcceptedResult(),
QueueShowScanResult.AlreadyScanning => ApiResults.ConflictProblem(
"Library scan in progress",
$"A scan for library {id} is already in progress; cannot scan an individual show."),
QueueShowScanResult.SyncDisabled => new UnprocessableEntityObjectResult(
new ProblemDetails
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "Library sync is disabled",
Detail = $"Item sync is disabled for library {id}."
}),
QueueShowScanResult.Unsupported => new UnprocessableEntityObjectResult(
new ProblemDetails
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "Single show scanning is not supported",
Detail = $"Library {id} does not support scanning an individual show."
}),
QueueShowScanResult.ScanFailed => new UnprocessableEntityObjectResult(
new ProblemDetails
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "Unable to scan show",
Detail = $"The scan for show {request.ShowId} in library {id} could not be completed."
}),
_ => ApiResults.NotFoundProblem($"Library {id} does not exist.")
};
}
return ApiResults.NotFoundProblem($"Show {request.ShowId} does not exist in library {id}.");
@@ -277,6 +277,51 @@ public class PlexMediaSourcesController(
return new AcceptedResult();
}
[HttpPost("/api/media-sources/plex/{id:int}/scan-collections", Name = "ScanPlexCollections")]
[Tags("Plex")]
[EndpointSummary("Scan a Plex server's collections")]
[EndpointDescription(
"Queues a synchronization of the server's collections (fire-and-forget). Pass ?deep=true for a deep " +
"scan. Returns 409 while a Plex collections scan is already in progress.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> ScanCollections(
int id,
[FromQuery] bool deep = false,
CancellationToken cancellationToken = default)
{
if (!await PlexSourceExists(id, cancellationToken))
{
return ApiResults.NotFoundProblem();
}
// The collections lock IS the running scan (§3b): fail to acquire = a scan is already active → 409.
if (!entityLocker.LockPlexCollections())
{
return ApiResults.ConflictProblem(
"Plex collections scan in progress",
"A Plex collections scan is already in progress; try again once it completes.");
}
try
{
await scannerWorkerChannel.WriteAsync(
new SynchronizePlexCollections(id, true, deep),
cancellationToken);
}
catch
{
// the scanner releases the lock when it processes the message; if the enqueue throws after we
// acquired the lock, release it here (EnqueueWithTraktLock compensating-unlock, §3b)
entityLocker.UnlockPlexCollections();
throw;
}
return new AcceptedResult();
}
private async Task<bool> PlexSourceExists(int id, CancellationToken cancellationToken) =>
(await mediator.Send(new GetPlexMediaSourceById(id), cancellationToken)).IsSome;
+3 -2
View File
@@ -307,8 +307,9 @@
private async Task ScanShow(bool deepScan)
{
bool result = await Mediator.Send(new QueueShowScanByLibraryId(_show.LibraryId, _show.Id, _show.Title, deepScan));
if (!result)
QueueShowScanResult result =
await Mediator.Send(new QueueShowScanByLibraryId(_show.LibraryId, _show.Id, _show.Title, deepScan));
if (result != QueueShowScanResult.Queued)
{
Snackbar.Add($"Unable to scan show {_show.Title}", Severity.Error);
}
+9
View File
@@ -135,6 +135,15 @@ operation itself, not a mutation racing it). Add `[ProducesResponseType]` for 20
`EnqueueWithTraktLock` compensating-unlock pattern (`TraktController`): if a `WriteAsync` throws
after a successful `Lock*`, `Unlock*` in a `catch` and rethrow — one lock ⇄ exactly one release.
A second exemplar (issue #235 slice B), where the lock lives on the **controller** rather than in a
handler: `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=` acquires the
per-source collections lock (`entityLocker.LockPlexCollections()` etc.) — the lock IS the running
collections scan, so a `false` = 409 — then `WriteAsync`es `Synchronize{X}Collections(id, ForceScan:
true, deep)` to the scanner channel and returns **202**. `ScannerService` releases that lock in a
`finally` when it processes the message; the controller compensating-unlocks in a `catch` if the
enqueue throws. `POST /api/libraries/{id}/scan?deep=` similarly threads an optional `[FromQuery] bool
deep` into `QueueLibraryScanByLibraryId(id, DeepScan)`.
`NotFoundError : BaseError` lives in `ErsatzTV.Core/Errors/NotFoundError.cs` — return it from a
handler's validation when a lookup fails, so the controller-side mapping falls out for free.
+4 -2
View File
@@ -270,8 +270,10 @@ intentional exit ramp until phase (b) removes Blazor entirely.
## Section 5 — Removal execution runbook (#91 phase b, Step 2/3)
The removal PR is **gated** — it starts only after these clear: ~~#202 (media-source write API + SPA)~~
**DONE 2026-07-11**, #235 F9 (deep-scan + external-collections-scan API — no API today, so `Libraries.razor`
can't be deleted yet), and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). (#204's id-carrying pattern
**DONE 2026-07-11**, ~~#235 F9 (deep-scan + external-collections-scan API)~~ **API DONE (#235 slice B)**:
`POST /api/libraries/{id}/scan?deep=` now threads deep-scan, and `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=`
covers external-collections scan (the two `Libraries.razor` parity gaps) — the SPA `Libraries.razor` port can now
proceed, and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). (#204's id-carrying pattern
redirects landed 2026-07-11; its catch-all fallback that replaces `MapFallbackToPage("/_Host")` is folded
into Step 2 below, since it can only ship when `_Host` is deleted.) When those close, the removal-PR routine
executes, in order: