Merge branch 'feat/235-s2-libraries' into feat/235-async-contract
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user