Merge branch 'feat/235-s2-libraries' into feat/235-async-contract

This commit is contained in:
2026-07-11 18:02:58 +02:00
17 changed files with 704 additions and 42 deletions
@@ -8,4 +8,4 @@ public enum QueueLibraryScanResult
AlreadyScanning 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), new SynchronizePlexLibraries(library.MediaSourceId),
cancellationToken); cancellationToken);
await scannerWorker.WriteAsync( await scannerWorker.WriteAsync(
new ForceSynchronizePlexLibraryById(library.Id, false), new ForceSynchronizePlexLibraryById(library.Id, request.DeepScan),
cancellationToken); cancellationToken);
break; break;
case JellyfinLibrary: case JellyfinLibrary:
@@ -74,7 +74,7 @@ public class QueueLibraryScanByLibraryIdHandler(
new SynchronizeJellyfinLibraries(library.MediaSourceId), new SynchronizeJellyfinLibraries(library.MediaSourceId),
cancellationToken); cancellationToken);
await scannerWorker.WriteAsync( await scannerWorker.WriteAsync(
new ForceSynchronizeJellyfinLibraryById(library.Id, false), new ForceSynchronizeJellyfinLibraryById(library.Id, request.DeepScan),
cancellationToken); cancellationToken);
break; break;
case EmbyLibrary: case EmbyLibrary:
@@ -82,7 +82,7 @@ public class QueueLibraryScanByLibraryIdHandler(
new SynchronizeEmbyLibraries(library.MediaSourceId), new SynchronizeEmbyLibraries(library.MediaSourceId),
cancellationToken); cancellationToken);
await scannerWorker.WriteAsync( await scannerWorker.WriteAsync(
new ForceSynchronizeEmbyLibraryById(library.Id, false), new ForceSynchronizeEmbyLibraryById(library.Id, request.DeepScan),
cancellationToken); cancellationToken);
break; break;
} }
@@ -1,3 +1,14 @@
namespace ErsatzTV.Application.Libraries; 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, IMediator mediator,
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<QueueShowScanByLibraryIdHandler> logger) 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); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -42,14 +42,14 @@ public class QueueShowScanByLibraryIdHandler(
if (!shouldSyncItems) if (!shouldSyncItems)
{ {
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id); 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)) if (!locker.LockLibrary(library.Id))
{ {
logger.LogWarning("Library {Id} is already being scanned, cannot scan individual show", library.Id); logger.LogWarning("Library {Id} is already being scanned, cannot scan individual show", library.Id);
return false; return QueueShowScanResult.AlreadyScanning;
} }
logger.LogDebug( logger.LogDebug(
@@ -60,41 +60,43 @@ public class QueueShowScanByLibraryIdHandler(
try try
{ {
var success = false; QueueShowScanResult outcome;
switch (library) switch (library)
{ {
case PlexLibrary: case PlexLibrary:
Either<BaseError, string> plexResult = await mediator.Send( Either<BaseError, string> plexResult = await mediator.Send(
new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan), new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken); cancellationToken);
success = plexResult.IsRight; outcome = plexResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
break; break;
case JellyfinLibrary: case JellyfinLibrary:
Either<BaseError, string> jellyfinResult = await mediator.Send( Either<BaseError, string> jellyfinResult = await mediator.Send(
new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan), new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken); cancellationToken);
success = jellyfinResult.IsRight; outcome = jellyfinResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
break; break;
case EmbyLibrary: case EmbyLibrary:
Either<BaseError, string> embyResult = await mediator.Send( Either<BaseError, string> embyResult = await mediator.Send(
new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan), new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken); cancellationToken);
success = embyResult.IsRight; outcome = embyResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
break; break;
case LocalLibrary: case LocalLibrary:
logger.LogWarning("Single show scanning is not supported for local libraries"); logger.LogWarning("Single show scanning is not supported for local libraries");
outcome = QueueShowScanResult.Unsupported;
break; break;
default: default:
logger.LogWarning("Unknown library type for library {Id}", library.Id); logger.LogWarning("Unknown library type for library {Id}", library.Id);
outcome = QueueShowScanResult.Unsupported;
break; break;
} }
if (success && request.DeepScan) if (outcome == QueueShowScanResult.Queued && request.DeepScan)
{ {
await workerChannel.WriteAsync(new ExtractEmbeddedShowSubtitles(request.ShowId), cancellationToken); await workerChannel.WriteAsync(new ExtractEmbeddedShowSubtitles(request.ShowId), cancellationToken);
} }
return success; return outcome;
} }
finally 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); 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( private QueueLibraryScanByLibraryIdHandler CreateHandler(
IEntityLocker locker, IEntityLocker locker,
ChannelWriter<IScannerBackgroundServiceRequest> writer) => ChannelWriter<IScannerBackgroundServiceRequest> writer) =>
@@ -193,7 +217,11 @@ public class QueueLibraryScanByLibraryIdHandlerTests
return source.Libraries[0].Id; 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(); await using TvContext context = _db.CreateContext();
var source = new PlexMediaSource var source = new PlexMediaSource
@@ -212,7 +240,7 @@ public class QueueLibraryScanByLibraryIdHandlerTests
Name = "Plex Movies", Name = "Plex Movies",
MediaKind = LibraryMediaKind.Movies, MediaKind = LibraryMediaKind.Movies,
Key = "1", Key = "1",
ShouldSyncItems = false, ShouldSyncItems = shouldSyncItems,
Paths = [] 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), nameof(EmbyMediaSourcesController.RefreshLibraries),
"POST", "POST",
"/api/media-sources/emby/{id:int}/refresh-libraries"); "/api/media-sources/emby/{id:int}/refresh-libraries");
ShouldHaveActionRoute(
nameof(EmbyMediaSourcesController.ScanCollections),
"POST",
"/api/media-sources/emby/{id:int}/scan-collections");
} }
[Test] [Test]
@@ -414,6 +418,48 @@ public class EmbyMediaSourcesControllerTests
request.ShouldBeOfType<SynchronizeEmbyLibraries>().EmbyMediaSourceId.ShouldBe(1); 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) private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{ {
MethodInfo action = typeof(EmbyMediaSourcesController).GetMethod(actionName) MethodInfo action = typeof(EmbyMediaSourcesController).GetMethod(actionName)
@@ -75,6 +75,10 @@ public class JellyfinMediaSourcesControllerTests
nameof(JellyfinMediaSourcesController.RefreshLibraries), nameof(JellyfinMediaSourcesController.RefreshLibraries),
"POST", "POST",
"/api/media-sources/jellyfin/{id:int}/refresh-libraries"); "/api/media-sources/jellyfin/{id:int}/refresh-libraries");
ShouldHaveActionRoute(
nameof(JellyfinMediaSourcesController.ScanCollections),
"POST",
"/api/media-sources/jellyfin/{id:int}/scan-collections");
} }
[Test] [Test]
@@ -414,6 +418,48 @@ public class JellyfinMediaSourcesControllerTests
request.ShouldBeOfType<SynchronizeJellyfinLibraries>().JellyfinMediaSourceId.ShouldBe(1); 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) private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{ {
MethodInfo action = typeof(JellyfinMediaSourcesController).GetMethod(actionName) MethodInfo action = typeof(JellyfinMediaSourcesController).GetMethod(actionName)
@@ -71,14 +71,15 @@ public class LibrariesControllerTests
} }
[Test] [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")); _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)); IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42, DeepScan: true));
result.ShouldBeOfType<OkResult>(); result.ShouldBeOfType<AcceptedResult>();
await _mediator.Received(1).Send( await _mediator.Received(1).Send(
Arg.Is<QueueShowScanByLibraryId>(r => Arg.Is<QueueShowScanByLibraryId>(r =>
r.LibraryId == 3 && r.ShowId == 42 && r.ShowTitle == "The Office" && r.DeepScan), r.LibraryId == 3 && r.ShowId == 42 && r.ShowTitle == "The Office" && r.DeepScan),
@@ -86,14 +87,68 @@ public class LibrariesControllerTests
} }
[Test] [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")); _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)); 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] [Test]
@@ -102,11 +157,25 @@ public class LibrariesControllerTests
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>()) _mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.Queued); .Returns(QueueLibraryScanResult.Queued);
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None); IActionResult result = await _controller.ScanLibrary(7, cancellationToken: CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>(); result.ShouldBeOfType<AcceptedResult>();
await _mediator.Received(1).Send( 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>()); Arg.Any<CancellationToken>());
} }
@@ -116,7 +185,7 @@ public class LibrariesControllerTests
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>()) _mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.NotFound); .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>(); var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound); notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
@@ -128,7 +197,7 @@ public class LibrariesControllerTests
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>()) _mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.AlreadyScanning); .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>(); var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status409Conflict); conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status409Conflict);
@@ -140,7 +209,7 @@ public class LibrariesControllerTests
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>()) _mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.SyncDisabled); .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>(); var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity); unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity);
@@ -70,6 +70,10 @@ public class PlexMediaSourcesControllerTests
nameof(PlexMediaSourcesController.RefreshLibraries), nameof(PlexMediaSourcesController.RefreshLibraries),
"POST", "POST",
"/api/media-sources/plex/{id:int}/refresh-libraries"); "/api/media-sources/plex/{id:int}/refresh-libraries");
ShouldHaveActionRoute(
nameof(PlexMediaSourcesController.ScanCollections),
"POST",
"/api/media-sources/plex/{id:int}/scan-collections");
} }
// ----- P1 GetState ----- // ----- P1 GetState -----
@@ -452,6 +456,64 @@ public class PlexMediaSourcesControllerTests
Arg.Any<CancellationToken>()); 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) => private void SourceExists(bool exists) =>
_mediator.Send(Arg.Any<GetPlexMediaSourceById>(), Arg.Any<CancellationToken>()) _mediator.Send(Arg.Any<GetPlexMediaSourceById>(), Arg.Any<CancellationToken>())
.Returns(exists .Returns(exists
@@ -278,6 +278,53 @@ public class EmbyMediaSourcesController(
return new AcceptedResult(); 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 // §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). // (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity).
private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken) private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken)
@@ -278,6 +278,53 @@ public class JellyfinMediaSourcesController(
return new AcceptedResult(); 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 // §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). // (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity).
private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken) 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")] [HttpPost("/api/libraries/{id:int}/scan")]
[Tags("Libraries")] [Tags("Libraries")]
[EndpointSummary("Scan library")] [EndpointSummary("Scan library")]
[EndpointDescription("Queues a scan of the whole library. Pass ?deep=true for a deep (metadata-refresh) scan.")]
[ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [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 return result switch
{ {
QueueLibraryScanResult.Queued => new AcceptedResult(), QueueLibraryScanResult.Queued => new AcceptedResult(),
@@ -49,19 +54,47 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
[HttpPost("/api/libraries/{id:int}/scan-show")] [HttpPost("/api/libraries/{id:int}/scan-show")]
[Tags("Libraries")] [Tags("Libraries")]
[EndpointSummary("Scan show")] [EndpointSummary("Scan show")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [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) public async Task<IActionResult> ScanShow(int id, [FromBody] ScanShowRequest request)
{ {
Option<string> maybeTitle = await televisionRepository.GetShowTitle(id, request.ShowId); Option<string> maybeTitle = await televisionRepository.GetShowTitle(id, request.ShowId);
foreach (string title in maybeTitle) 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 return result switch
? 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." }); 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}."); return ApiResults.NotFoundProblem($"Show {request.ShowId} does not exist in library {id}.");
@@ -277,6 +277,51 @@ public class PlexMediaSourcesController(
return new AcceptedResult(); 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) => private async Task<bool> PlexSourceExists(int id, CancellationToken cancellationToken) =>
(await mediator.Send(new GetPlexMediaSourceById(id), cancellationToken)).IsSome; (await mediator.Send(new GetPlexMediaSourceById(id), cancellationToken)).IsSome;
+3 -2
View File
@@ -307,8 +307,9 @@
private async Task ScanShow(bool deepScan) private async Task ScanShow(bool deepScan)
{ {
bool result = await Mediator.Send(new QueueShowScanByLibraryId(_show.LibraryId, _show.Id, _show.Title, deepScan)); QueueShowScanResult result =
if (!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); Snackbar.Add($"Unable to scan show {_show.Title}", Severity.Error);
} }
+9
View File
@@ -144,6 +144,15 @@ operation itself, not a mutation racing it). Add `[ProducesResponseType]` for 20
`EnqueueWithTraktLock` compensating-unlock pattern (`TraktController`): if a `WriteAsync` throws `EnqueueWithTraktLock` compensating-unlock pattern (`TraktController`): if a `WriteAsync` throws
after a successful `Lock*`, `Unlock*` in a `catch` and rethrow — one lock ⇄ exactly one release. 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 `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. 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) ## 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)~~ 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` **DONE 2026-07-11**, ~~#235 F9 (deep-scan + external-collections-scan API)~~ **API DONE (#235 slice B)**:
can't be deleted yet), and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). (#204's id-carrying pattern `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 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 into Step 2 below, since it can only ship when `_Host` is deleted.) When those close, the removal-PR routine
executes, in order: executes, in order: