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
@@ -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;