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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user