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