feat(235): async-op API contract normalization + playout build observability + F9 scan endpoints #267
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan)
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan, bool Unlock = true)
|
||||
: IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan, bool DeepScan) :
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan, bool DeepScan, bool Unlock = true) :
|
||||
IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -8,4 +8,4 @@ public enum QueueLibraryScanResult
|
||||
AlreadyScanning
|
||||
}
|
||||
|
||||
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<QueueLibraryScanResult>;
|
||||
public record QueueLibraryScanByLibraryId(int LibraryId, bool DeepScan = false) : IRequest<QueueLibraryScanResult>;
|
||||
|
||||
@@ -66,7 +66,7 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
new SynchronizePlexLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizePlexLibraryById(library.Id, false),
|
||||
new ForceSynchronizePlexLibraryById(library.Id, request.DeepScan),
|
||||
cancellationToken);
|
||||
break;
|
||||
case JellyfinLibrary:
|
||||
@@ -74,7 +74,7 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
new SynchronizeJellyfinLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizeJellyfinLibraryById(library.Id, false),
|
||||
new ForceSynchronizeJellyfinLibraryById(library.Id, request.DeepScan),
|
||||
cancellationToken);
|
||||
break;
|
||||
case EmbyLibrary:
|
||||
@@ -82,7 +82,7 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
new SynchronizeEmbyLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizeEmbyLibraryById(library.Id, false),
|
||||
new ForceSynchronizeEmbyLibraryById(library.Id, request.DeepScan),
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan) : IRequest<bool>;
|
||||
public enum QueueShowScanResult
|
||||
{
|
||||
Queued,
|
||||
NotFound,
|
||||
Unsupported,
|
||||
SyncDisabled,
|
||||
AlreadyScanning,
|
||||
ScanFailed
|
||||
}
|
||||
|
||||
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan)
|
||||
: IRequest<QueueShowScanResult>;
|
||||
|
||||
@@ -19,9 +19,9 @@ public class QueueShowScanByLibraryIdHandler(
|
||||
IMediator mediator,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ILogger<QueueShowScanByLibraryIdHandler> logger)
|
||||
: IRequestHandler<QueueShowScanByLibraryId, bool>
|
||||
: IRequestHandler<QueueShowScanByLibraryId, QueueShowScanResult>
|
||||
{
|
||||
public async Task<bool> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken)
|
||||
public async Task<QueueShowScanResult> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
@@ -42,14 +42,14 @@ public class QueueShowScanByLibraryIdHandler(
|
||||
if (!shouldSyncItems)
|
||||
{
|
||||
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id);
|
||||
return false;
|
||||
return QueueShowScanResult.SyncDisabled;
|
||||
}
|
||||
|
||||
// Check if library is already being scanned - return false if locked
|
||||
// A false from LockLibrary means a scan is already in progress; we own no release.
|
||||
if (!locker.LockLibrary(library.Id))
|
||||
{
|
||||
logger.LogWarning("Library {Id} is already being scanned, cannot scan individual show", library.Id);
|
||||
return false;
|
||||
return QueueShowScanResult.AlreadyScanning;
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
@@ -60,41 +60,43 @@ public class QueueShowScanByLibraryIdHandler(
|
||||
|
||||
try
|
||||
{
|
||||
var success = false;
|
||||
QueueShowScanResult outcome;
|
||||
switch (library)
|
||||
{
|
||||
case PlexLibrary:
|
||||
Either<BaseError, string> plexResult = await mediator.Send(
|
||||
new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan),
|
||||
cancellationToken);
|
||||
success = plexResult.IsRight;
|
||||
outcome = plexResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
|
||||
break;
|
||||
case JellyfinLibrary:
|
||||
Either<BaseError, string> jellyfinResult = await mediator.Send(
|
||||
new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan),
|
||||
cancellationToken);
|
||||
success = jellyfinResult.IsRight;
|
||||
outcome = jellyfinResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
|
||||
break;
|
||||
case EmbyLibrary:
|
||||
Either<BaseError, string> embyResult = await mediator.Send(
|
||||
new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan),
|
||||
cancellationToken);
|
||||
success = embyResult.IsRight;
|
||||
outcome = embyResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
|
||||
break;
|
||||
case LocalLibrary:
|
||||
logger.LogWarning("Single show scanning is not supported for local libraries");
|
||||
outcome = QueueShowScanResult.Unsupported;
|
||||
break;
|
||||
default:
|
||||
logger.LogWarning("Unknown library type for library {Id}", library.Id);
|
||||
outcome = QueueShowScanResult.Unsupported;
|
||||
break;
|
||||
}
|
||||
|
||||
if (success && request.DeepScan)
|
||||
if (outcome == QueueShowScanResult.Queued && request.DeepScan)
|
||||
{
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedShowSubtitles(request.ShowId), cancellationToken);
|
||||
}
|
||||
|
||||
return success;
|
||||
return outcome;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -103,6 +105,6 @@ public class QueueShowScanByLibraryIdHandler(
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return QueueShowScanResult.NotFound;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ResetAllPlayouts : IRequest;
|
||||
public record ResetAllPlayouts : IRequest<ResetAllPlayoutsResult>;
|
||||
|
||||
@@ -11,33 +11,49 @@ public class ResetAllPlayoutsHandler(
|
||||
IEntityLocker locker,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ResetAllPlayouts>
|
||||
: IRequestHandler<ResetAllPlayouts, ResetAllPlayoutsResult>
|
||||
{
|
||||
public async Task Handle(ResetAllPlayouts request, CancellationToken cancellationToken)
|
||||
public async Task<ResetAllPlayoutsResult> Handle(
|
||||
ResetAllPlayouts request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var queued = new List<int>();
|
||||
var skippedLocked = new List<int>();
|
||||
var skippedUnsupported = new List<int>();
|
||||
|
||||
foreach (Playout playout in await dbContext.Playouts.ToListAsync(cancellationToken))
|
||||
{
|
||||
switch (playout.ScheduleKind)
|
||||
{
|
||||
case PlayoutScheduleKind.Classic:
|
||||
if (!locker.IsPlayoutLocked(playout.Id))
|
||||
if (locker.IsPlayoutLocked(playout.Id))
|
||||
{
|
||||
skippedLocked.Add(playout.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh),
|
||||
cancellationToken);
|
||||
queued.Add(playout.Id);
|
||||
}
|
||||
|
||||
break;
|
||||
case PlayoutScheduleKind.Block:
|
||||
case PlayoutScheduleKind.Sequential:
|
||||
case PlayoutScheduleKind.Scripted:
|
||||
if (!locker.IsPlayoutLocked(playout.Id))
|
||||
if (locker.IsPlayoutLocked(playout.Id))
|
||||
{
|
||||
skippedLocked.Add(playout.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
|
||||
cancellationToken);
|
||||
queued.Add(playout.Id);
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -45,8 +61,11 @@ public class ResetAllPlayoutsHandler(
|
||||
case PlayoutScheduleKind.None:
|
||||
default:
|
||||
// external json cannot be reset
|
||||
skippedUnsupported.Add(playout.Id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return new ResetAllPlayoutsResult(queued, skippedLocked, skippedUnsupported);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ResetAllPlayoutsResult(
|
||||
List<int> QueuedPlayoutIds,
|
||||
List<int> SkippedLocked,
|
||||
List<int> SkippedUnsupported);
|
||||
@@ -2,5 +2,5 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan, bool DeepScan)
|
||||
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan, bool DeepScan, bool Unlock = true)
|
||||
: IRequest<Either<BaseError, Unit>>, IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -14,7 +14,8 @@ public record PlayoutResponseModel(
|
||||
TimeSpan? DailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? BuildStatus,
|
||||
int? DecoId,
|
||||
string? DecoName)
|
||||
string? DecoName,
|
||||
bool IsLocked)
|
||||
{
|
||||
public static PlayoutResponseModel From(
|
||||
int id,
|
||||
@@ -27,7 +28,8 @@ public record PlayoutResponseModel(
|
||||
TimeSpan? dailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? buildStatus,
|
||||
int? decoId,
|
||||
string? decoName) =>
|
||||
string? decoName,
|
||||
bool isLocked) =>
|
||||
new(
|
||||
id,
|
||||
scheduleKind,
|
||||
@@ -39,5 +41,6 @@ public record PlayoutResponseModel(
|
||||
dailyRebuildTime,
|
||||
buildStatus,
|
||||
decoId,
|
||||
decoName);
|
||||
decoName,
|
||||
isLocked);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record ResetAllPlayoutsResponseModel(
|
||||
List<int> QueuedPlayoutIds,
|
||||
List<int> SkippedLocked,
|
||||
List<int> SkippedUnsupported);
|
||||
@@ -164,6 +164,30 @@ public class QueueLibraryScanByLibraryIdHandlerTests
|
||||
locker.Received(1).UnlockLibrary(libraryId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Thread_DeepScan_Into_Plex_ForceSynchronize()
|
||||
{
|
||||
int libraryId = await SeedSyncEnabledPlexLibrary();
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(libraryId).Returns(true);
|
||||
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
||||
|
||||
QueueLibraryScanResult result = await handler.Handle(
|
||||
new QueueLibraryScanByLibraryId(libraryId, DeepScan: true),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueLibraryScanResult.Queued);
|
||||
|
||||
// first message refreshes the library list, second is the deep force-sync carrying DeepScan == true
|
||||
channel.Reader.TryRead(out IScannerBackgroundServiceRequest? first).ShouldBeTrue();
|
||||
first.ShouldBeOfType<ErsatzTV.Application.Plex.SynchronizePlexLibraries>();
|
||||
channel.Reader.TryRead(out IScannerBackgroundServiceRequest? second).ShouldBeTrue();
|
||||
second.ShouldBeOfType<ErsatzTV.Application.Plex.ForceSynchronizePlexLibraryById>().DeepScan.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private QueueLibraryScanByLibraryIdHandler CreateHandler(
|
||||
IEntityLocker locker,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> writer) =>
|
||||
@@ -193,7 +217,11 @@ public class QueueLibraryScanByLibraryIdHandlerTests
|
||||
return source.Libraries[0].Id;
|
||||
}
|
||||
|
||||
private async Task<int> SeedSyncDisabledPlexLibrary()
|
||||
private async Task<int> SeedSyncDisabledPlexLibrary() => await SeedPlexLibrary(shouldSyncItems: false);
|
||||
|
||||
private async Task<int> SeedSyncEnabledPlexLibrary() => await SeedPlexLibrary(shouldSyncItems: true);
|
||||
|
||||
private async Task<int> SeedPlexLibrary(bool shouldSyncItems)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var source = new PlexMediaSource
|
||||
@@ -212,7 +240,7 @@ public class QueueLibraryScanByLibraryIdHandlerTests
|
||||
Name = "Plex Movies",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Key = "1",
|
||||
ShouldSyncItems = false,
|
||||
ShouldSyncItems = shouldSyncItems,
|
||||
Paths = []
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Application.Plex;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using ThreadingChannel = System.Threading.Channels.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Libraries;
|
||||
|
||||
[TestFixture]
|
||||
public class QueueShowScanByLibraryIdHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_NotFound_When_Library_Missing()
|
||||
{
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
|
||||
QueueShowScanByLibraryIdHandler handler = CreateHandler(locker, mediator, out _);
|
||||
|
||||
QueueShowScanResult result = await handler.Handle(
|
||||
new QueueShowScanByLibraryId(9999, 1, "Show", false),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueShowScanResult.NotFound);
|
||||
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_SyncDisabled_When_Item_Sync_Off()
|
||||
{
|
||||
int libraryId = await SeedPlexLibrary(shouldSyncItems: false);
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
|
||||
QueueShowScanByLibraryIdHandler handler = CreateHandler(locker, mediator, out _);
|
||||
|
||||
QueueShowScanResult result = await handler.Handle(
|
||||
new QueueShowScanByLibraryId(libraryId, 1, "Show", false),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueShowScanResult.SyncDisabled);
|
||||
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_AlreadyScanning_When_Lock_Not_Acquired()
|
||||
{
|
||||
int libraryId = await SeedPlexLibrary(shouldSyncItems: true);
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(libraryId).Returns(false);
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
|
||||
QueueShowScanByLibraryIdHandler handler = CreateHandler(locker, mediator, out _);
|
||||
|
||||
QueueShowScanResult result = await handler.Handle(
|
||||
new QueueShowScanByLibraryId(libraryId, 1, "Show", false),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueShowScanResult.AlreadyScanning);
|
||||
await mediator.DidNotReceive().Send(Arg.Any<SynchronizePlexShowById>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Unsupported_For_Local_Library()
|
||||
{
|
||||
int libraryId = await SeedLocalLibrary();
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(libraryId).Returns(true);
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
|
||||
QueueShowScanByLibraryIdHandler handler = CreateHandler(locker, mediator, out _);
|
||||
|
||||
QueueShowScanResult result = await handler.Handle(
|
||||
new QueueShowScanByLibraryId(libraryId, 1, "Show", false),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueShowScanResult.Unsupported);
|
||||
locker.Received(1).UnlockLibrary(libraryId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Queued_And_Extract_Subtitles_On_Deep_Success()
|
||||
{
|
||||
int libraryId = await SeedPlexLibrary(shouldSyncItems: true);
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(libraryId).Returns(true);
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
mediator.Send(Arg.Any<SynchronizePlexShowById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, string>("ok"));
|
||||
|
||||
QueueShowScanByLibraryIdHandler handler = CreateHandler(
|
||||
locker,
|
||||
mediator,
|
||||
out Channel<IBackgroundServiceRequest> worker);
|
||||
|
||||
QueueShowScanResult result = await handler.Handle(
|
||||
new QueueShowScanByLibraryId(libraryId, 42, "Show", DeepScan: true),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueShowScanResult.Queued);
|
||||
locker.Received(1).UnlockLibrary(libraryId);
|
||||
worker.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
request.ShouldBeOfType<ExtractEmbeddedShowSubtitles>().ShowId.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_ScanFailed_When_SubScan_Left()
|
||||
{
|
||||
int libraryId = await SeedPlexLibrary(shouldSyncItems: true);
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(libraryId).Returns(true);
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
mediator.Send(Arg.Any<SynchronizePlexShowById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, string>(BaseError.New("scan error")));
|
||||
|
||||
QueueShowScanByLibraryIdHandler handler = CreateHandler(
|
||||
locker,
|
||||
mediator,
|
||||
out Channel<IBackgroundServiceRequest> worker);
|
||||
|
||||
QueueShowScanResult result = await handler.Handle(
|
||||
new QueueShowScanByLibraryId(libraryId, 42, "Show", DeepScan: true),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueShowScanResult.ScanFailed);
|
||||
locker.Received(1).UnlockLibrary(libraryId);
|
||||
// no subtitle extraction on a failed scan
|
||||
worker.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private QueueShowScanByLibraryIdHandler CreateHandler(
|
||||
IEntityLocker locker,
|
||||
IMediator mediator,
|
||||
out Channel<IBackgroundServiceRequest> worker)
|
||||
{
|
||||
worker = ThreadingChannel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
return new QueueShowScanByLibraryIdHandler(
|
||||
_db.Factory,
|
||||
locker,
|
||||
mediator,
|
||||
worker.Writer,
|
||||
NullLogger<QueueShowScanByLibraryIdHandler>.Instance);
|
||||
}
|
||||
|
||||
private async Task<int> SeedLocalLibrary()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var source = new LocalMediaSource
|
||||
{
|
||||
Libraries =
|
||||
[
|
||||
new LocalLibrary
|
||||
{
|
||||
Name = "Local Movies",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = []
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.LocalMediaSources.AddAsync(source);
|
||||
await context.SaveChangesAsync();
|
||||
return source.Libraries[0].Id;
|
||||
}
|
||||
|
||||
private async Task<int> SeedPlexLibrary(bool shouldSyncItems)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var source = new PlexMediaSource
|
||||
{
|
||||
ServerName = "Plex Server",
|
||||
ProductVersion = "1",
|
||||
Platform = "Linux",
|
||||
PlatformVersion = "1",
|
||||
ClientIdentifier = "plex",
|
||||
Connections = [],
|
||||
PathReplacements = [],
|
||||
Libraries =
|
||||
[
|
||||
new PlexLibrary
|
||||
{
|
||||
Name = "Plex Shows",
|
||||
MediaKind = LibraryMediaKind.Shows,
|
||||
Key = "1",
|
||||
ShouldSyncItems = shouldSyncItems,
|
||||
Paths = []
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.PlexMediaSources.AddAsync(source);
|
||||
await context.SaveChangesAsync();
|
||||
return source.Libraries[0].Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Channel = System.Threading.Channels.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Playouts;
|
||||
|
||||
[TestFixture]
|
||||
public class ResetAllPlayoutsHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private Channel<IBackgroundServiceRequest> _worker = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private ResetAllPlayoutsHandler CreateHandler() =>
|
||||
new(_entityLocker, _worker.Writer, _db.Factory);
|
||||
|
||||
private async Task<int> SeedPlayout(PlayoutScheduleKind kind)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var playout = new Playout { ChannelId = 0, ScheduleKind = kind };
|
||||
context.Playouts.Add(playout);
|
||||
await context.SaveChangesAsync();
|
||||
return playout.Id;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Queue_Eligible_And_Report_Skipped()
|
||||
{
|
||||
int classicId = await SeedPlayout(PlayoutScheduleKind.Classic);
|
||||
int blockId = await SeedPlayout(PlayoutScheduleKind.Block);
|
||||
int lockedId = await SeedPlayout(PlayoutScheduleKind.Sequential);
|
||||
int externalJsonId = await SeedPlayout(PlayoutScheduleKind.ExternalJson);
|
||||
int noneId = await SeedPlayout(PlayoutScheduleKind.None);
|
||||
|
||||
_entityLocker.IsPlayoutLocked(lockedId).Returns(true);
|
||||
|
||||
ResetAllPlayoutsResult result =
|
||||
await CreateHandler().Handle(new ResetAllPlayouts(), CancellationToken.None);
|
||||
|
||||
// eligible, unlocked playouts are queued
|
||||
result.QueuedPlayoutIds.ShouldBe(new List<int> { classicId, blockId }, ignoreOrder: true);
|
||||
|
||||
// locked playout lands in SkippedLocked, not queued
|
||||
result.SkippedLocked.ShouldBe(new List<int> { lockedId });
|
||||
|
||||
// ExternalJson + None land in SkippedUnsupported
|
||||
result.SkippedUnsupported.ShouldBe(new List<int> { externalJsonId, noneId }, ignoreOrder: true);
|
||||
|
||||
// exactly one BuildPlayout message per queued playout was enqueued
|
||||
var enqueued = new List<int>();
|
||||
while (_worker.Reader.TryRead(out IBackgroundServiceRequest? request))
|
||||
{
|
||||
var build = request.ShouldBeOfType<BuildPlayout>();
|
||||
enqueued.Add(build.PlayoutId);
|
||||
}
|
||||
|
||||
enqueued.ShouldBe(new List<int> { classicId, blockId }, ignoreOrder: true);
|
||||
}
|
||||
}
|
||||
@@ -407,7 +407,7 @@ public class ChannelControllerTests
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
|
||||
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
var buildPlayout = request.ShouldBeOfType<BuildPlayout>();
|
||||
buildPlayout.PlayoutId.ShouldBe(9);
|
||||
@@ -436,7 +436,7 @@ public class ChannelControllerTests
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
|
||||
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
request.ShouldBeOfType<BuildPlayout>().Mode.ShouldBe(PlayoutBuildMode.Continue);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
|
||||
|
||||
@@ -75,6 +75,10 @@ public class EmbyMediaSourcesControllerTests
|
||||
nameof(EmbyMediaSourcesController.RefreshLibraries),
|
||||
"POST",
|
||||
"/api/media-sources/emby/{id:int}/refresh-libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.ScanCollections),
|
||||
"POST",
|
||||
"/api/media-sources/emby/{id:int}/scan-collections");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -414,6 +418,48 @@ public class EmbyMediaSourcesControllerTests
|
||||
request.ShouldBeOfType<SynchronizeEmbyLibraries>().EmbyMediaSourceId.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanCollections_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.ScanCollections(99, cancellationToken: CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
_entityLocker.DidNotReceive().LockEmbyCollections();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanCollections_Should_Return_409_When_Collections_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.Some(new EmbyMediaSourceViewModel(1, "s", "a")));
|
||||
_entityLocker.LockEmbyCollections().Returns(false);
|
||||
|
||||
IActionResult result = await _controller.ScanCollections(1, cancellationToken: CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
_scannerChannel.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanCollections_Should_Enqueue_And_Return_202()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.Some(new EmbyMediaSourceViewModel(1, "s", "a")));
|
||||
_entityLocker.LockEmbyCollections().Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ScanCollections(1, deep: true, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
_scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
var command = request.ShouldBeOfType<SynchronizeEmbyCollections>();
|
||||
command.EmbyMediaSourceId.ShouldBe(1);
|
||||
command.ForceScan.ShouldBeTrue();
|
||||
command.DeepScan.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(EmbyMediaSourcesController).GetMethod(actionName)
|
||||
|
||||
@@ -75,6 +75,10 @@ public class JellyfinMediaSourcesControllerTests
|
||||
nameof(JellyfinMediaSourcesController.RefreshLibraries),
|
||||
"POST",
|
||||
"/api/media-sources/jellyfin/{id:int}/refresh-libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.ScanCollections),
|
||||
"POST",
|
||||
"/api/media-sources/jellyfin/{id:int}/scan-collections");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -414,6 +418,48 @@ public class JellyfinMediaSourcesControllerTests
|
||||
request.ShouldBeOfType<SynchronizeJellyfinLibraries>().JellyfinMediaSourceId.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanCollections_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.ScanCollections(99, cancellationToken: CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
_entityLocker.DidNotReceive().LockJellyfinCollections();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanCollections_Should_Return_409_When_Collections_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.Some(new JellyfinMediaSourceViewModel(1, "s", "a")));
|
||||
_entityLocker.LockJellyfinCollections().Returns(false);
|
||||
|
||||
IActionResult result = await _controller.ScanCollections(1, cancellationToken: CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
_scannerChannel.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanCollections_Should_Enqueue_And_Return_202()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.Some(new JellyfinMediaSourceViewModel(1, "s", "a")));
|
||||
_entityLocker.LockJellyfinCollections().Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ScanCollections(1, deep: true, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
_scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
var command = request.ShouldBeOfType<SynchronizeJellyfinCollections>();
|
||||
command.JellyfinMediaSourceId.ShouldBe(1);
|
||||
command.ForceScan.ShouldBeTrue();
|
||||
command.DeepScan.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(JellyfinMediaSourcesController).GetMethod(actionName)
|
||||
|
||||
@@ -71,14 +71,15 @@ public class LibrariesControllerTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanShow_Should_Queue_Scan_By_Show_Id_When_Show_Belongs_To_Library()
|
||||
public async Task ScanShow_Should_Return_202_And_Queue_Scan_By_Show_Id_When_Show_Belongs_To_Library()
|
||||
{
|
||||
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
|
||||
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueShowScanResult.Queued);
|
||||
|
||||
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42, DeepScan: true));
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<QueueShowScanByLibraryId>(r =>
|
||||
r.LibraryId == 3 && r.ShowId == 42 && r.ShowTitle == "The Office" && r.DeepScan),
|
||||
@@ -86,14 +87,68 @@ public class LibrariesControllerTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanShow_Should_Return_BadRequest_When_Mediator_Fails_To_Queue()
|
||||
public async Task ScanShow_Should_Return_409_When_AlreadyScanning()
|
||||
{
|
||||
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
|
||||
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueShowScanResult.AlreadyScanning);
|
||||
|
||||
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
|
||||
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanShow_Should_Return_422_When_SyncDisabled()
|
||||
{
|
||||
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
|
||||
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueShowScanResult.SyncDisabled);
|
||||
|
||||
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanShow_Should_Return_422_When_Unsupported()
|
||||
{
|
||||
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
|
||||
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueShowScanResult.Unsupported);
|
||||
|
||||
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanShow_Should_Return_422_When_ScanFailed()
|
||||
{
|
||||
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
|
||||
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueShowScanResult.ScanFailed);
|
||||
|
||||
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanShow_Should_Return_404_When_Handler_Reports_NotFound()
|
||||
{
|
||||
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
|
||||
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueShowScanResult.NotFound);
|
||||
|
||||
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -102,11 +157,25 @@ public class LibrariesControllerTests
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.Queued);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
|
||||
IActionResult result = await _controller.ScanLibrary(7, cancellationToken: CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<QueueLibraryScanByLibraryId>(r => r.LibraryId == 7),
|
||||
Arg.Is<QueueLibraryScanByLibraryId>(r => r.LibraryId == 7 && !r.DeepScan),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Pass_DeepScan_When_Deep_Query_True()
|
||||
{
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.Queued);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, deep: true, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<QueueLibraryScanByLibraryId>(r => r.LibraryId == 7 && r.DeepScan),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
@@ -116,7 +185,7 @@ public class LibrariesControllerTests
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.NotFound);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
|
||||
IActionResult result = await _controller.ScanLibrary(7, cancellationToken: CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
|
||||
@@ -128,7 +197,7 @@ public class LibrariesControllerTests
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.AlreadyScanning);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
|
||||
IActionResult result = await _controller.ScanLibrary(7, cancellationToken: CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status409Conflict);
|
||||
@@ -140,7 +209,7 @@ public class LibrariesControllerTests
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.SyncDisabled);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
|
||||
IActionResult result = await _controller.ScanLibrary(7, cancellationToken: CancellationToken.None);
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class MaintenanceControllerTests
|
||||
{
|
||||
private MaintenanceController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_workerChannel = Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
_controller = new MaintenanceController(_mediator, _workerChannel.Writer);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EmptyTrash_Should_Return_200_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<EmptyTrash>())
|
||||
.Returns(Right<BaseError, LanguageExt.Unit>(unit));
|
||||
|
||||
IActionResult result = await _controller.EmptyTrash();
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _mediator.Received(1).Send(Arg.Any<EmptyTrash>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EmptyTrash_Should_Return_422_ProblemDetails_On_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<EmptyTrash>())
|
||||
.Returns(Left<BaseError, LanguageExt.Unit>(BaseError.New("Failed to empty trash")));
|
||||
|
||||
IActionResult result = await _controller.EmptyTrash();
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problem.Status.ShouldBe(422);
|
||||
problem.Detail.ShouldBe("Failed to empty trash");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CleanArtwork_Should_Return_202_And_Enqueue_DeleteOrphanedArtwork()
|
||||
{
|
||||
IActionResult result = await _controller.CleanArtwork(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
|
||||
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? enqueued).ShouldBeTrue();
|
||||
enqueued.ShouldBeOfType<DeleteOrphanedArtwork>();
|
||||
}
|
||||
}
|
||||
@@ -527,6 +527,21 @@ public class PlayoutControllerTests
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(ToResponse(vm));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Expose_IsLocked_From_EntityLocker()
|
||||
{
|
||||
PlayoutNameViewModel vm = MakePlayout(9);
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(vm));
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.GetById(9, CancellationToken.None);
|
||||
|
||||
var body = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlayoutResponseModel>();
|
||||
body.IsLocked.ShouldBeTrue();
|
||||
body.ShouldBe(ToResponse(vm, isLocked: true));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None_With_ProblemDetails()
|
||||
{
|
||||
@@ -693,9 +708,17 @@ public class PlayoutControllerTests
|
||||
[Test]
|
||||
public async Task ResetAll_Should_Return_202_And_Send_Command()
|
||||
{
|
||||
_mediator.Send(Arg.Any<ResetAllPlayouts>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ResetAllPlayoutsResult([1, 2], [3], [4]));
|
||||
|
||||
IActionResult result = await _controller.ResetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
var accepted = result.ShouldBeOfType<AcceptedResult>();
|
||||
accepted.StatusCode.ShouldBe(202);
|
||||
var body = accepted.Value.ShouldBeOfType<ResetAllPlayoutsResponseModel>();
|
||||
body.QueuedPlayoutIds.ShouldBe(new List<int> { 1, 2 });
|
||||
body.SkippedLocked.ShouldBe(new List<int> { 3 });
|
||||
body.SkippedUnsupported.ShouldBe(new List<int> { 4 });
|
||||
await _mediator.Received(1).Send(Arg.Any<ResetAllPlayouts>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
@@ -1286,7 +1309,7 @@ public class PlayoutControllerTests
|
||||
null,
|
||||
null);
|
||||
|
||||
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) =>
|
||||
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked = false) =>
|
||||
PlayoutResponseModel.From(
|
||||
vm.PlayoutId,
|
||||
vm.ScheduleKind,
|
||||
@@ -1303,7 +1326,8 @@ public class PlayoutControllerTests
|
||||
vm.BuildStatus.Success,
|
||||
vm.BuildStatus.Message),
|
||||
vm.DecoId,
|
||||
vm.DecoName);
|
||||
vm.DecoName,
|
||||
isLocked);
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
|
||||
@@ -70,6 +70,10 @@ public class PlexMediaSourcesControllerTests
|
||||
nameof(PlexMediaSourcesController.RefreshLibraries),
|
||||
"POST",
|
||||
"/api/media-sources/plex/{id:int}/refresh-libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.ScanCollections),
|
||||
"POST",
|
||||
"/api/media-sources/plex/{id:int}/scan-collections");
|
||||
}
|
||||
|
||||
// ----- P1 GetState -----
|
||||
@@ -452,6 +456,64 @@ public class PlexMediaSourcesControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- P9 ScanCollections -----
|
||||
|
||||
[Test]
|
||||
public async Task ScanCollections_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
SourceExists(false);
|
||||
|
||||
IActionResult result = await _controller.ScanCollections(9, cancellationToken: CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
_entityLocker.DidNotReceive().LockPlexCollections();
|
||||
await _channel.DidNotReceive().WriteAsync(
|
||||
Arg.Any<IScannerBackgroundServiceRequest>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanCollections_Should_Return_409_When_Collections_Locked()
|
||||
{
|
||||
SourceExists(true);
|
||||
_entityLocker.LockPlexCollections().Returns(false);
|
||||
|
||||
IActionResult result = await _controller.ScanCollections(3, cancellationToken: CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _channel.DidNotReceive().WriteAsync(
|
||||
Arg.Any<IScannerBackgroundServiceRequest>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanCollections_Should_Return_202_And_Enqueue()
|
||||
{
|
||||
SourceExists(true);
|
||||
_entityLocker.LockPlexCollections().Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ScanCollections(3, deep: true, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
await _channel.Received(1).WriteAsync(
|
||||
Arg.Is<SynchronizePlexCollections>(s => s.PlexMediaSourceId == 3 && s.ForceScan && s.DeepScan),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanCollections_Should_Compensate_Unlock_When_Enqueue_Throws()
|
||||
{
|
||||
SourceExists(true);
|
||||
_entityLocker.LockPlexCollections().Returns(true);
|
||||
_channel.WriteAsync(Arg.Any<SynchronizePlexCollections>(), Arg.Any<CancellationToken>())
|
||||
.Returns<ValueTask>(_ => throw new InvalidOperationException("channel closed"));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => _controller.ScanCollections(3, cancellationToken: CancellationToken.None));
|
||||
|
||||
_entityLocker.Received(1).UnlockPlexCollections();
|
||||
}
|
||||
|
||||
private void SourceExists(bool exists) =>
|
||||
_mediator.Send(Arg.Any<GetPlexMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(exists
|
||||
|
||||
@@ -12,6 +12,7 @@ using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Troubleshooting;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
@@ -274,6 +275,55 @@ public class TroubleshootControllerTests
|
||||
.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TroubleshootPlayback_Should_Return_404_ProblemDetails_When_Prepare_Not_Found()
|
||||
{
|
||||
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
|
||||
_mediator.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Either<BaseError, PlayoutItemResult>.Left(
|
||||
new ErsatzTV.Core.Errors.NotFoundError("no such media item")));
|
||||
|
||||
IActionResult result = await _controller.TroubleshootPlayback(
|
||||
mediaItem: 999,
|
||||
channel: 0,
|
||||
ffmpegProfile: 1,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
watermark: [],
|
||||
graphicsElement: [],
|
||||
streamSelector: string.Empty,
|
||||
subtitleId: null,
|
||||
seekSeconds: 0,
|
||||
start: null,
|
||||
CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TroubleshootPlayback_Should_Return_422_ProblemDetails_When_Prepare_Fails_Validation()
|
||||
{
|
||||
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
|
||||
_mediator.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Either<BaseError, PlayoutItemResult>.Left(BaseError.New("unable to prepare")));
|
||||
|
||||
IActionResult result = await _controller.TroubleshootPlayback(
|
||||
mediaItem: 1,
|
||||
channel: 0,
|
||||
ffmpegProfile: 1,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
watermark: [],
|
||||
graphicsElement: [],
|
||||
streamSelector: string.Empty,
|
||||
subtitleId: null,
|
||||
seekSeconds: 0,
|
||||
start: null,
|
||||
CancellationToken.None);
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(422);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Reflection;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Application.Plex;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Locking;
|
||||
using ErsatzTV.Services;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Regression net for the #250 cross-release bug on the per-provider collections lock (#235):
|
||||
/// a scheduler-enqueued collection scan running with <c>Unlock: false</c> must NOT release a
|
||||
/// collections lock owned by another party (an in-flight API scan), while <c>Unlock: true</c>
|
||||
/// (an API request or the last message of a scheduler batch) must release on completion.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ScannerServiceCollectionLockTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Plex_UnlockFalse_DoesNotReleaseHeldLock()
|
||||
{
|
||||
(ScannerService service, EntityLocker locker) = BuildService(
|
||||
new SynchronizePlexCollections(1, false, false, Unlock: false));
|
||||
|
||||
// another party (an API scan) owns the collections lock
|
||||
locker.LockPlexCollections().ShouldBeTrue();
|
||||
|
||||
await RunToCompletion(service);
|
||||
|
||||
locker.ArePlexCollectionsLocked().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Plex_UnlockTrue_ReleasesHeldLock()
|
||||
{
|
||||
(ScannerService service, EntityLocker locker) = BuildService(
|
||||
new SynchronizePlexCollections(1, false, false, Unlock: true));
|
||||
|
||||
locker.LockPlexCollections().ShouldBeTrue();
|
||||
|
||||
await RunToCompletion(service);
|
||||
|
||||
locker.ArePlexCollectionsLocked().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Jellyfin_UnlockFalse_DoesNotReleaseHeldLock()
|
||||
{
|
||||
(ScannerService service, EntityLocker locker) = BuildService(
|
||||
new SynchronizeJellyfinCollections(1, false, false, Unlock: false));
|
||||
|
||||
locker.LockJellyfinCollections().ShouldBeTrue();
|
||||
|
||||
await RunToCompletion(service);
|
||||
|
||||
locker.AreJellyfinCollectionsLocked().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Jellyfin_UnlockTrue_ReleasesHeldLock()
|
||||
{
|
||||
(ScannerService service, EntityLocker locker) = BuildService(
|
||||
new SynchronizeJellyfinCollections(1, false, false, Unlock: true));
|
||||
|
||||
locker.LockJellyfinCollections().ShouldBeTrue();
|
||||
|
||||
await RunToCompletion(service);
|
||||
|
||||
locker.AreJellyfinCollectionsLocked().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Emby_UnlockFalse_DoesNotReleaseHeldLock()
|
||||
{
|
||||
(ScannerService service, EntityLocker locker) = BuildService(
|
||||
new SynchronizeEmbyCollections(1, false, false, Unlock: false));
|
||||
|
||||
locker.LockEmbyCollections().ShouldBeTrue();
|
||||
|
||||
await RunToCompletion(service);
|
||||
|
||||
locker.AreEmbyCollectionsLocked().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Emby_UnlockTrue_ReleasesHeldLock()
|
||||
{
|
||||
(ScannerService service, EntityLocker locker) = BuildService(
|
||||
new SynchronizeEmbyCollections(1, false, false, Unlock: true));
|
||||
|
||||
locker.LockEmbyCollections().ShouldBeTrue();
|
||||
|
||||
await RunToCompletion(service);
|
||||
|
||||
locker.AreEmbyCollectionsLocked().ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static (ScannerService, EntityLocker) BuildService(IScannerBackgroundServiceRequest request)
|
||||
{
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
// every collection sync request returns success; the finally is what we're exercising
|
||||
Either<BaseError, LanguageExt.Unit> ok = LanguageExt.Unit.Default;
|
||||
mediator.Send(Arg.Any<SynchronizePlexCollections>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ok);
|
||||
mediator.Send(Arg.Any<SynchronizeJellyfinCollections>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ok);
|
||||
mediator.Send(Arg.Any<SynchronizeEmbyCollections>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ok);
|
||||
|
||||
var locker = new EntityLocker(mediator, NullLogger<EntityLocker>.Instance);
|
||||
|
||||
var provider = Substitute.For<IServiceProvider>();
|
||||
provider.GetService(typeof(IMediator)).Returns(mediator);
|
||||
provider.GetService(typeof(IEntityLocker)).Returns(locker);
|
||||
|
||||
var scope = Substitute.For<IServiceScope>();
|
||||
scope.ServiceProvider.Returns(provider);
|
||||
|
||||
var scopeFactory = Substitute.For<IServiceScopeFactory>();
|
||||
scopeFactory.CreateScope().Returns(scope);
|
||||
|
||||
var channel = Channel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
channel.Writer.TryWrite(request).ShouldBeTrue();
|
||||
channel.Writer.Complete();
|
||||
|
||||
var startup = new SystemStartup();
|
||||
startup.DatabaseIsReady();
|
||||
startup.SearchIndexIsReady();
|
||||
|
||||
var service = new ScannerService(
|
||||
channel.Reader,
|
||||
scopeFactory,
|
||||
startup,
|
||||
NullLogger<ScannerService>.Instance);
|
||||
|
||||
return (service, locker);
|
||||
}
|
||||
|
||||
private static async Task RunToCompletion(ScannerService service)
|
||||
{
|
||||
MethodInfo executeAsync = typeof(ScannerService)
|
||||
.GetMethod("ExecuteAsync", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
|
||||
// the channel writer is already completed, so the read loop drains the one queued
|
||||
// request, runs its finally, then ExecuteAsync returns
|
||||
var task = (Task)executeAsync.Invoke(service, [CancellationToken.None])!;
|
||||
await task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Reflection;
|
||||
using System.Threading.Channels;
|
||||
using SysChannels = System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Plex;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Services;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the scheduler's lock-once-per-provider collection batch (#235): it acquires
|
||||
/// <c>LockPlexCollections()</c> exactly once, and when the slot is already held (an API scan or a
|
||||
/// prior tick) it SKIPS the collection enqueue entirely rather than enqueuing an unlocked scan
|
||||
/// that would cross-release the holder's lock (#250). When it does acquire, only the LAST message
|
||||
/// carries the release (<c>Unlock: true</c>).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class SchedulerServiceCollectionLockTests
|
||||
{
|
||||
private InMemoryTvContext _db;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Plex_SkipsCollectionEnqueue_WhenCollectionsLockAlreadyHeld()
|
||||
{
|
||||
await SeedPlexLibrary(id: 1, mediaSourceId: 7);
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
// isolate the collection path: the library loop enqueues nothing but still records the source id
|
||||
locker.LockLibrary(Arg.Any<int>()).Returns(false);
|
||||
// the collections slot is already held by another party
|
||||
locker.LockPlexCollections().Returns(false);
|
||||
|
||||
(SchedulerService service, SysChannels.Channel<IScannerBackgroundServiceRequest> scannerChannel) = BuildService(locker);
|
||||
|
||||
await InvokeScanPlex(service);
|
||||
List<IScannerBackgroundServiceRequest> writes = await Drain(scannerChannel);
|
||||
|
||||
locker.Received(1).LockPlexCollections();
|
||||
writes.OfType<SynchronizePlexCollections>().ShouldBeEmpty();
|
||||
locker.DidNotReceive().UnlockPlexCollections();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Plex_EnqueuesBatch_LastMessageOwnsRelease_WhenLockAcquired()
|
||||
{
|
||||
await SeedPlexLibrary(id: 1, mediaSourceId: 7);
|
||||
await SeedPlexLibrary(id: 2, mediaSourceId: 8);
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(Arg.Any<int>()).Returns(false);
|
||||
locker.LockPlexCollections().Returns(true);
|
||||
|
||||
(SchedulerService service, SysChannels.Channel<IScannerBackgroundServiceRequest> scannerChannel) = BuildService(locker);
|
||||
|
||||
await InvokeScanPlex(service);
|
||||
List<IScannerBackgroundServiceRequest> writes = await Drain(scannerChannel);
|
||||
|
||||
locker.Received(1).LockPlexCollections();
|
||||
|
||||
List<SynchronizePlexCollections> collectionScans = writes.OfType<SynchronizePlexCollections>().ToList();
|
||||
collectionScans.Count.ShouldBe(2);
|
||||
// exactly one message owns the single release, and it is the last one written
|
||||
collectionScans.Count(m => m.Unlock).ShouldBe(1);
|
||||
collectionScans.Last().Unlock.ShouldBeTrue();
|
||||
collectionScans.First().Unlock.ShouldBeFalse();
|
||||
|
||||
// release is handed off to the last message; no compensating unlock on the happy path
|
||||
locker.DidNotReceive().UnlockPlexCollections();
|
||||
}
|
||||
|
||||
private async Task SeedPlexLibrary(int id, int mediaSourceId)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.PlexLibraries.Add(
|
||||
new PlexLibrary
|
||||
{
|
||||
Id = id,
|
||||
Name = $"Plex {id}",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
MediaSourceId = mediaSourceId,
|
||||
ShouldSyncItems = true,
|
||||
Key = $"key-{id}"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private (SchedulerService, SysChannels.Channel<IScannerBackgroundServiceRequest>) BuildService(IEntityLocker locker)
|
||||
{
|
||||
var provider = Substitute.For<IServiceProvider>();
|
||||
provider.GetService(typeof(TvContext)).Returns(_ => _db.CreateContext());
|
||||
|
||||
var scope = Substitute.For<IServiceScope>();
|
||||
scope.ServiceProvider.Returns(provider);
|
||||
|
||||
var scopeFactory = Substitute.For<IServiceScopeFactory>();
|
||||
scopeFactory.CreateScope().Returns(scope);
|
||||
|
||||
var workerChannel = SysChannels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
var scannerChannel = SysChannels.Channel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
|
||||
var startup = new SystemStartup();
|
||||
|
||||
var service = new SchedulerService(
|
||||
scopeFactory,
|
||||
workerChannel.Writer,
|
||||
scannerChannel.Writer,
|
||||
locker,
|
||||
startup,
|
||||
NullLogger<SchedulerService>.Instance);
|
||||
|
||||
return (service, scannerChannel);
|
||||
}
|
||||
|
||||
private static async Task<List<IScannerBackgroundServiceRequest>> Drain(
|
||||
SysChannels.Channel<IScannerBackgroundServiceRequest> channel)
|
||||
{
|
||||
channel.Writer.Complete();
|
||||
var results = new List<IScannerBackgroundServiceRequest>();
|
||||
await foreach (IScannerBackgroundServiceRequest request in channel.Reader.ReadAllAsync())
|
||||
{
|
||||
results.Add(request);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static async Task InvokeScanPlex(SchedulerService service)
|
||||
{
|
||||
MethodInfo method = typeof(SchedulerService)
|
||||
.GetMethod("ScanPlexMediaSources", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
var task = (Task)method.Invoke(service, [CancellationToken.None])!;
|
||||
await task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Services;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Services;
|
||||
|
||||
[TestFixture]
|
||||
public class WorkerServiceTests
|
||||
{
|
||||
// F7 regression (issue #235): the global Trakt lock is acquired by SchedulerService and released
|
||||
// only when the *terminal* (Unlock: true) message of a batch is processed by WorkerService. If the
|
||||
// worker stops before reaching that terminal message (shutdown break / channel completion /
|
||||
// cancellation), the release never fires and the in-memory Trakt lock leaks for the life of the
|
||||
// process. WorkerService must release a held Trakt lock as a compensating action on shutdown.
|
||||
[Test]
|
||||
public async Task Should_Release_Held_Trakt_Lock_When_Worker_Stops_Before_Terminal_Message()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
|
||||
// A Trakt batch: only the terminal message carries Unlock: true. If the worker never processes
|
||||
// it, the handler-side UnlockTrakt() never runs.
|
||||
AddTraktList nonTerminal = AddTraktList.Existing("user", "list-1", false);
|
||||
AddTraktList terminal = AddTraktList.Existing("user", "list-2", true);
|
||||
await channel.Writer.WriteAsync(nonTerminal);
|
||||
await channel.Writer.WriteAsync(terminal);
|
||||
|
||||
// Stateful stand-in for the singleton EntityLocker: the lock starts held (a batch acquired it).
|
||||
var traktLocked = 1;
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.IsTraktLocked().Returns(_ => Volatile.Read(ref traktLocked) == 1);
|
||||
locker.UnlockTrakt().Returns(_ => Interlocked.Exchange(ref traktLocked, 0) == 1);
|
||||
|
||||
// Gate: processing the first (non-terminal) message parks on the stopping token, guaranteeing
|
||||
// the worker never reaches the terminal (Unlock: true) message before it is stopped.
|
||||
var firstSeen = new TaskCompletionSource();
|
||||
var processed = new List<AddTraktList>();
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
mediator.Send(Arg.Any<AddTraktList>(), Arg.Any<CancellationToken>())
|
||||
.Returns(async call =>
|
||||
{
|
||||
lock (processed)
|
||||
{
|
||||
processed.Add(call.Arg<AddTraktList>());
|
||||
}
|
||||
|
||||
firstSeen.TrySetResult();
|
||||
|
||||
// Block on the stopping token so the loop parks here until StopAsync cancels it.
|
||||
await Task.Delay(Timeout.Infinite, call.Arg<CancellationToken>());
|
||||
return (Either<BaseError, Unit>)Unit.Default;
|
||||
});
|
||||
|
||||
var provider = Substitute.For<IServiceProvider>();
|
||||
provider.GetService(typeof(IMediator)).Returns(mediator);
|
||||
var scope = Substitute.For<IServiceScope>();
|
||||
scope.ServiceProvider.Returns(provider);
|
||||
var scopeFactory = Substitute.For<IServiceScopeFactory>();
|
||||
scopeFactory.CreateScope().Returns(scope);
|
||||
|
||||
var worker = new WorkerService(
|
||||
channel.Reader,
|
||||
scopeFactory,
|
||||
locker,
|
||||
NullLogger<WorkerService>.Instance);
|
||||
|
||||
await worker.StartAsync(CancellationToken.None);
|
||||
|
||||
// Wait until the first message is actively being processed (parked on the stopping token).
|
||||
await firstSeen.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Shut the worker down: cancels the stopping token -> parked Delay throws -> loop breaks
|
||||
// before the terminal message is ever processed.
|
||||
await worker.StopAsync(CancellationToken.None);
|
||||
|
||||
// The compensating release must have fired even though the terminal message was never handled.
|
||||
locker.IsTraktLocked().ShouldBeFalse();
|
||||
locker.Received(1).UnlockTrakt();
|
||||
|
||||
// Prove the leak scenario is genuine: we stopped after the non-terminal message but before the
|
||||
// terminal (Unlock: true) one, so the handler-side release could not have run.
|
||||
List<AddTraktList> seen;
|
||||
lock (processed)
|
||||
{
|
||||
seen = processed.ToList();
|
||||
}
|
||||
|
||||
seen.ShouldContain(nonTerminal);
|
||||
seen.ShouldNotContain(terminal);
|
||||
}
|
||||
}
|
||||
@@ -211,7 +211,7 @@ public class ChannelController(
|
||||
"progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. " +
|
||||
"Pass mode to force a specific PlayoutBuildMode.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> ResetPlayout(
|
||||
@@ -233,7 +233,7 @@ public class ChannelController(
|
||||
|
||||
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
|
||||
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
|
||||
return new OkResult();
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
return ApiResults.NotFoundProblem();
|
||||
|
||||
@@ -278,6 +278,53 @@ public class EmbyMediaSourcesController(
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/emby/{id:int}/scan-collections", Name = "ScanEmbyCollections")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Scan an Emby source's collections")]
|
||||
[EndpointDescription(
|
||||
"Queues a synchronization of the source's collections (fire-and-forget). Pass ?deep=true for a deep " +
|
||||
"scan. Returns 409 while an Emby collections scan is already in progress.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> ScanCollections(
|
||||
int id,
|
||||
[FromQuery] bool deep = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Option<EmbyMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The collections lock IS the running scan (§3b): fail to acquire = a scan is already active → 409.
|
||||
if (!entityLocker.LockEmbyCollections())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Emby collections scan in progress",
|
||||
"An Emby collections scan is already in progress; try again once it completes.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeEmbyCollections(id, true, deep),
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner releases the lock when it processes the message; if the enqueue throws after we
|
||||
// acquired the lock, release it here (EnqueueWithTraktLock compensating-unlock, §3b)
|
||||
entityLocker.UnlockEmbyCollections();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
// §C7: LockLibrary then enqueue the sync pair; a throw from the enqueue compensates by unlocking
|
||||
// (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity).
|
||||
private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken)
|
||||
|
||||
@@ -278,6 +278,53 @@ public class JellyfinMediaSourcesController(
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/jellyfin/{id:int}/scan-collections", Name = "ScanJellyfinCollections")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Scan a Jellyfin source's collections")]
|
||||
[EndpointDescription(
|
||||
"Queues a synchronization of the source's collections (fire-and-forget). Pass ?deep=true for a deep " +
|
||||
"scan. Returns 409 while a Jellyfin collections scan is already in progress.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> ScanCollections(
|
||||
int id,
|
||||
[FromQuery] bool deep = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Option<JellyfinMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The collections lock IS the running scan (§3b): fail to acquire = a scan is already active → 409.
|
||||
if (!entityLocker.LockJellyfinCollections())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Jellyfin collections scan in progress",
|
||||
"A Jellyfin collections scan is already in progress; try again once it completes.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeJellyfinCollections(id, true, deep),
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner releases the lock when it processes the message; if the enqueue throws after we
|
||||
// acquired the lock, release it here (EnqueueWithTraktLock compensating-unlock, §3b)
|
||||
entityLocker.UnlockJellyfinCollections();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
// §C7: LockLibrary then enqueue the sync pair; a throw from the enqueue compensates by unlocking
|
||||
// (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity).
|
||||
private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -22,13 +23,18 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
|
||||
[HttpPost("/api/libraries/{id:int}/scan")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan library")]
|
||||
[EndpointDescription("Queues a scan of the whole library. Pass ?deep=true for a deep (metadata-refresh) scan.")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ScanLibrary(int id, CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> ScanLibrary(
|
||||
int id,
|
||||
[FromQuery] bool deep = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
QueueLibraryScanResult result = await mediator.Send(new QueueLibraryScanByLibraryId(id), cancellationToken);
|
||||
QueueLibraryScanResult result =
|
||||
await mediator.Send(new QueueLibraryScanByLibraryId(id, deep), cancellationToken);
|
||||
return result switch
|
||||
{
|
||||
QueueLibraryScanResult.Queued => new AcceptedResult(),
|
||||
@@ -49,19 +55,48 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
|
||||
[HttpPost("/api/libraries/{id:int}/scan-show")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan show")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ScanShow(int id, [FromBody] ScanShowRequest request)
|
||||
{
|
||||
Option<string> maybeTitle = await televisionRepository.GetShowTitle(id, request.ShowId);
|
||||
foreach (string title in maybeTitle)
|
||||
{
|
||||
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, request.ShowId, title, request.DeepScan));
|
||||
QueueShowScanResult result =
|
||||
await mediator.Send(new QueueShowScanByLibraryId(id, request.ShowId, title, request.DeepScan));
|
||||
|
||||
return result
|
||||
? new OkResult()
|
||||
: new BadRequestObjectResult(new { error = "Unable to queue show scan. Library may not exist, may not support single show scanning, or may already be scanning." });
|
||||
return result switch
|
||||
{
|
||||
QueueShowScanResult.Queued => new AcceptedResult(),
|
||||
QueueShowScanResult.AlreadyScanning => ApiResults.ConflictProblem(
|
||||
"Library scan in progress",
|
||||
$"A scan for library {id} is already in progress; cannot scan an individual show."),
|
||||
QueueShowScanResult.SyncDisabled => new UnprocessableEntityObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status422UnprocessableEntity,
|
||||
Title = "Library sync is disabled",
|
||||
Detail = $"Item sync is disabled for library {id}."
|
||||
}),
|
||||
QueueShowScanResult.Unsupported => new UnprocessableEntityObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status422UnprocessableEntity,
|
||||
Title = "Single show scanning is not supported",
|
||||
Detail = $"Library {id} does not support scanning an individual show."
|
||||
}),
|
||||
QueueShowScanResult.ScanFailed => new UnprocessableEntityObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status422UnprocessableEntity,
|
||||
Title = "Unable to scan show",
|
||||
Detail = $"The scan for show {request.ShowId} in library {id} could not be completed."
|
||||
}),
|
||||
QueueShowScanResult.NotFound => ApiResults.NotFoundProblem($"Library {id} does not exist."),
|
||||
_ => throw new UnreachableException($"Unmapped QueueShowScanResult: {result}")
|
||||
};
|
||||
}
|
||||
|
||||
return ApiResults.NotFoundProblem($"Show {request.ShowId} does not exist in library {id}.");
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -23,17 +24,14 @@ public class MaintenanceController(IMediator mediator, ChannelWriter<IBackground
|
||||
[HttpPost("/api/maintenance/empty_trash")]
|
||||
[Tags("Maintenance")]
|
||||
[EndpointSummary("Empty trash")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> EmptyTrash()
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new EmptyTrash());
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
return new ContentResult
|
||||
{
|
||||
StatusCode = StatusCodes.Status500InternalServerError,
|
||||
Content = error.ToString(),
|
||||
ContentType = "text/plain"
|
||||
};
|
||||
return error.ToErrorResult();
|
||||
}
|
||||
|
||||
return new OkResult();
|
||||
@@ -42,9 +40,10 @@ public class MaintenanceController(IMediator mediator, ChannelWriter<IBackground
|
||||
[HttpPost("/api/maintenance/clean_artwork")]
|
||||
[Tags("Maintenance")]
|
||||
[EndpointSummary("Clean artwork cache")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
public async Task<IActionResult> CleanArtwork(CancellationToken cancellationToken)
|
||||
{
|
||||
await workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken);
|
||||
return new OkResult();
|
||||
return new AcceptedResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlayoutNameViewModel> result = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
return result.Map(ToResponse).ToGetResult();
|
||||
return result.Map(vm => ToResponse(vm, entityLocker.IsPlayoutLocked(id))).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")]
|
||||
@@ -128,7 +128,9 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
Option<PlayoutNameViewModel> playout =
|
||||
await mediator.Send(new GetPlayoutById(created.PlayoutId), cancellationToken);
|
||||
return playout.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/playouts/{vm.PlayoutId}", ToResponse(vm)),
|
||||
Some: vm => (IActionResult)new CreatedResult(
|
||||
$"/api/playouts/{vm.PlayoutId}",
|
||||
ToResponse(vm, entityLocker.IsPlayoutLocked(vm.PlayoutId))),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
});
|
||||
@@ -194,7 +196,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
|
||||
return result.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: playout => (IActionResult)new OkObjectResult(ToResponse(playout)));
|
||||
Right: playout => (IActionResult)new OkObjectResult(
|
||||
ToResponse(playout, entityLocker.IsPlayoutLocked(id))));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlayoutNameViewModel>> UpdateScheduleFile(
|
||||
@@ -260,7 +263,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
|
||||
Option<PlayoutNameViewModel> refreshed = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(ToResponse(vm)),
|
||||
Some: vm => (IActionResult)new OkObjectResult(ToResponse(vm, entityLocker.IsPlayoutLocked(id))),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
}
|
||||
|
||||
@@ -550,14 +553,19 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Reset all playouts")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ResetAllPlayoutsResponseModel), StatusCodes.Status202Accepted)]
|
||||
// No 409 lock guard here (unlike the id-keyed mutations): ResetAllPlayoutsHandler already
|
||||
// skips any playout whose build lock is held, matching Blazor. It is a fire-and-forget
|
||||
// bulk enqueue, so it always accepts. See docs/decisions.md 2026-07-10.
|
||||
// bulk enqueue, so it always accepts — the 202 body reports which playouts were queued and
|
||||
// which were skipped (locked, or an unsupported ExternalJson/None kind). See docs/decisions.md 2026-07-10.
|
||||
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
|
||||
return Accepted();
|
||||
ResetAllPlayoutsResult result = await mediator.Send(new ResetAllPlayouts(), cancellationToken);
|
||||
var body = new ResetAllPlayoutsResponseModel(
|
||||
result.QueuedPlayoutIds,
|
||||
result.SkippedLocked,
|
||||
result.SkippedUnsupported);
|
||||
return new AcceptedResult((string)null, body);
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts/{id:int}/erase-items", Name = "ErasePlayoutItems")]
|
||||
@@ -728,7 +736,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
private static PlayoutHistoryDetailsResponseModel ToDetailsResponse(PlayoutHistoryDetailsViewModel vm) =>
|
||||
new(vm.PlaybackOrder, vm.CollectionType, vm.Name, vm.MediaItemType, vm.MediaItemTitle);
|
||||
|
||||
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) =>
|
||||
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked) =>
|
||||
PlayoutResponseModel.From(
|
||||
vm.PlayoutId,
|
||||
vm.ScheduleKind,
|
||||
@@ -740,7 +748,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus),
|
||||
vm.DecoId,
|
||||
vm.DecoName);
|
||||
vm.DecoName,
|
||||
isLocked);
|
||||
|
||||
private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) =>
|
||||
new(
|
||||
|
||||
@@ -277,6 +277,51 @@ public class PlexMediaSourcesController(
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/plex/{id:int}/scan-collections", Name = "ScanPlexCollections")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Scan a Plex server's collections")]
|
||||
[EndpointDescription(
|
||||
"Queues a synchronization of the server's collections (fire-and-forget). Pass ?deep=true for a deep " +
|
||||
"scan. Returns 409 while a Plex collections scan is already in progress.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> ScanCollections(
|
||||
int id,
|
||||
[FromQuery] bool deep = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await PlexSourceExists(id, cancellationToken))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The collections lock IS the running scan (§3b): fail to acquire = a scan is already active → 409.
|
||||
if (!entityLocker.LockPlexCollections())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Plex collections scan in progress",
|
||||
"A Plex collections scan is already in progress; try again once it completes.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexCollections(id, true, deep),
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner releases the lock when it processes the message; if the enqueue throws after we
|
||||
// acquired the lock, release it here (EnqueueWithTraktLock compensating-unlock, §3b)
|
||||
entityLocker.UnlockPlexCollections();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
private async Task<bool> PlexSourceExists(int id, CancellationToken cancellationToken) =>
|
||||
(await mediator.Send(new GetPlexMediaSourceById(id), cancellationToken)).IsSome;
|
||||
|
||||
|
||||
@@ -114,7 +114,9 @@ public class TroubleshootController(
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("Start a troubleshooting playback session")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> TroubleshootPlayback(
|
||||
[FromQuery]
|
||||
int mediaItem,
|
||||
@@ -174,9 +176,15 @@ public class TroubleshootController(
|
||||
Optional(start)),
|
||||
cancellationToken);
|
||||
|
||||
if (result.IsLeft)
|
||||
// Distinguish "prepare failed" from the later "no playable output" fall-through: map the
|
||||
// handler's BaseError through the standard helper (404 for NotFoundError — e.g. an unknown
|
||||
// media item/channel — else 422 for a validation failure) with a ProblemDetails body,
|
||||
// instead of a bare body-less 404. The SPA feeds this URL straight to hls.js (HlsPlayer)
|
||||
// and never inspects the status code — failures surface via the /status poll — so the
|
||||
// 404→422 split for validation errors is safe.
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
return NotFound();
|
||||
return error.ToErrorResult();
|
||||
}
|
||||
|
||||
// Prepare returned a process, so the handler holds the troubleshooting lock now
|
||||
@@ -273,7 +281,12 @@ public class TroubleshootController(
|
||||
}
|
||||
}
|
||||
|
||||
return NotFound();
|
||||
// Terminal fall-through: Prepare succeeded but no playable output was produced (playback
|
||||
// failed to start, was cancelled, or the segmenter never wrote segments). Keep the 404 status
|
||||
// the SPA player already tolerates, but attach a distinguishing ProblemDetails body rather
|
||||
// than a bare NotFound() so the response is self-describing.
|
||||
return ApiResults.NotFoundProblem(
|
||||
"Troubleshooting playback did not produce any output. It may have failed to start or been cancelled.");
|
||||
}
|
||||
|
||||
[HttpHead("api/troubleshoot/playback/archive")]
|
||||
|
||||
@@ -307,8 +307,9 @@
|
||||
|
||||
private async Task ScanShow(bool deepScan)
|
||||
{
|
||||
bool result = await Mediator.Send(new QueueShowScanByLibraryId(_show.LibraryId, _show.Id, _show.Title, deepScan));
|
||||
if (!result)
|
||||
QueueShowScanResult result =
|
||||
await Mediator.Send(new QueueShowScanByLibraryId(_show.LibraryId, _show.Id, _show.Title, deepScan));
|
||||
if (result != QueueShowScanResult.Queued)
|
||||
{
|
||||
Snackbar.Add($"Unable to scan show {_show.Title}", Severity.Error);
|
||||
}
|
||||
|
||||
@@ -221,7 +221,11 @@ public class ScannerService : BackgroundService
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (entityLocker.ArePlexCollectionsLocked())
|
||||
// request.Unlock is false when this collections scan is part of a scheduler-enqueued
|
||||
// per-provider batch and a later message owns the single release (see #250 / #235).
|
||||
// request.Unlock is false when this collections scan is part of a scheduler-enqueued
|
||||
// per-provider batch and a later message owns the single release (see #250 / #235).
|
||||
if (request.Unlock && entityLocker.ArePlexCollectionsLocked())
|
||||
{
|
||||
entityLocker.UnlockPlexCollections();
|
||||
}
|
||||
@@ -351,7 +355,9 @@ public class ScannerService : BackgroundService
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (entityLocker.AreJellyfinCollectionsLocked())
|
||||
// request.Unlock is false when this collections scan is part of a scheduler-enqueued
|
||||
// per-provider batch and a later message owns the single release (see #250 / #235).
|
||||
if (request.Unlock && entityLocker.AreJellyfinCollectionsLocked())
|
||||
{
|
||||
entityLocker.UnlockJellyfinCollections();
|
||||
}
|
||||
@@ -438,7 +444,9 @@ public class ScannerService : BackgroundService
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (entityLocker.AreEmbyCollectionsLocked())
|
||||
// request.Unlock is false when this collections scan is part of a scheduler-enqueued
|
||||
// per-provider batch and a later message owns the single release (see #250 / #235).
|
||||
if (request.Unlock && entityLocker.AreEmbyCollectionsLocked())
|
||||
{
|
||||
entityLocker.UnlockEmbyCollections();
|
||||
}
|
||||
|
||||
@@ -265,11 +265,35 @@ public class SchedulerService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
foreach (int mediaSourceId in mediaSourceIds)
|
||||
// lock the collections slot once for the whole provider batch: the LAST message owns the
|
||||
// single release (Unlock: true), the rest run Unlock: false. If the slot is already held
|
||||
// (an API scan or a prior tick's batch is running/queued), skip enqueuing entirely - never
|
||||
// enqueue unlocked, which would cross-release the holder's lock (#250). Skipping starves
|
||||
// nothing: the next tick retries. (see #235)
|
||||
// lock the collections slot once for the whole provider batch: the LAST message owns the
|
||||
// single release (Unlock: true), the rest run Unlock: false. If the slot is already held
|
||||
// (an API scan or a prior tick's batch is running/queued), skip enqueuing entirely - never
|
||||
// enqueue unlocked, which would cross-release the holder's lock (#250). Skipping starves
|
||||
// nothing: the next tick retries. (see #235)
|
||||
var plexSourceIds = mediaSourceIds.ToList();
|
||||
if (plexSourceIds.Count > 0 && _entityLocker.LockPlexCollections())
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexCollections(mediaSourceId, false, false),
|
||||
cancellationToken);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < plexSourceIds.Count; i++)
|
||||
{
|
||||
bool isLast = i == plexSourceIds.Count - 1;
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexCollections(plexSourceIds[i], false, false, Unlock: isLast),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// an enqueue threw before the last (releasing) message went out - compensate here
|
||||
_entityLocker.UnlockPlexCollections();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,11 +324,25 @@ public class SchedulerService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
foreach (int mediaSourceId in mediaSourceIds)
|
||||
// lock-once per provider batch; last message owns the release; skip entirely if held (#235/#250)
|
||||
var jellyfinSourceIds = mediaSourceIds.ToList();
|
||||
if (jellyfinSourceIds.Count > 0 && _entityLocker.LockJellyfinCollections())
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeJellyfinCollections(mediaSourceId, false, false),
|
||||
cancellationToken);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < jellyfinSourceIds.Count; i++)
|
||||
{
|
||||
bool isLast = i == jellyfinSourceIds.Count - 1;
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeJellyfinCollections(jellyfinSourceIds[i], false, false, Unlock: isLast),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_entityLocker.UnlockJellyfinCollections();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,11 +373,25 @@ public class SchedulerService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
foreach (int mediaSourceId in mediaSourceIds)
|
||||
// lock-once per provider batch; last message owns the release; skip entirely if held (#235/#250)
|
||||
var embySourceIds = mediaSourceIds.ToList();
|
||||
if (embySourceIds.Count > 0 && _entityLocker.LockEmbyCollections())
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeEmbyCollections(mediaSourceId, false, false),
|
||||
cancellationToken);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < embySourceIds.Count; i++)
|
||||
{
|
||||
bool isLast = i == embySourceIds.Count - 1;
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeEmbyCollections(embySourceIds[i], false, false, Unlock: isLast),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_entityLocker.UnlockEmbyCollections();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Services;
|
||||
@@ -16,16 +17,19 @@ namespace ErsatzTV.Services;
|
||||
public class WorkerService : BackgroundService
|
||||
{
|
||||
private readonly ChannelReader<IBackgroundServiceRequest> _channel;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ILogger<WorkerService> _logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
|
||||
public WorkerService(
|
||||
ChannelReader<IBackgroundServiceRequest> channel,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IEntityLocker entityLocker,
|
||||
ILogger<WorkerService> logger)
|
||||
{
|
||||
_channel = channel;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_entityLocker = entityLocker;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -143,5 +147,19 @@ public class WorkerService : BackgroundService
|
||||
{
|
||||
_logger.LogInformation("Worker service shutting down");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The global Trakt lock is acquired by SchedulerService/TraktController and released only
|
||||
// when the *terminal* (Unlock: true) message of a batch is processed here. If this loop
|
||||
// stops before reaching that message - shutdown break above, channel completion, or the
|
||||
// reader throwing on cancellation - the release never fires and the in-memory Trakt lock
|
||||
// leaks for the remaining life of the process (subsequent Trakt operations 409 forever).
|
||||
// Make the batch-release loss-tolerant with a compensating release on worker shutdown.
|
||||
if (_entityLocker.IsTraktLocked())
|
||||
{
|
||||
_logger.LogDebug("Releasing held Trakt lock during worker shutdown");
|
||||
_entityLocker.UnlockTrakt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1863,8 +1863,8 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"202": {
|
||||
"description": "Accepted"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
@@ -4839,6 +4839,80 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/media-sources/emby/{id}/scan-collections": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Emby"
|
||||
],
|
||||
"summary": "Scan an Emby source's collections",
|
||||
"description": "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.",
|
||||
"operationId": "ScanEmbyCollections",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deep",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/ffmpeg/profiles": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -6617,6 +6691,80 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/media-sources/jellyfin/{id}/scan-collections": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Jellyfin"
|
||||
],
|
||||
"summary": "Scan a Jellyfin source's collections",
|
||||
"description": "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.",
|
||||
"operationId": "ScanJellyfinCollections",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deep",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/languages": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -6703,6 +6851,7 @@
|
||||
"Libraries"
|
||||
],
|
||||
"summary": "Scan library",
|
||||
"description": "Queues a scan of the whole library. Pass ?deep=true for a deep (metadata-refresh) scan.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -6712,6 +6861,14 @@
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deep",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -6824,8 +6981,8 @@
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"202": {
|
||||
"description": "Accepted"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
@@ -6847,8 +7004,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
@@ -7589,6 +7766,26 @@
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7600,8 +7797,8 @@
|
||||
],
|
||||
"summary": "Clean artwork cache",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"202": {
|
||||
"description": "Accepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10499,7 +10696,24 @@
|
||||
"operationId": "ResetAllPlayouts",
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted"
|
||||
"description": "Accepted",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ResetAllPlayoutsResponseModel"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ResetAllPlayoutsResponseModel"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ResetAllPlayoutsResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11316,6 +11530,80 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/media-sources/plex/{id}/scan-collections": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Plex"
|
||||
],
|
||||
"summary": "Scan a Plex server's collections",
|
||||
"description": "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.",
|
||||
"operationId": "ScanPlexCollections",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deep",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/rerun-collections": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -15887,6 +16175,26 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
@@ -15906,6 +16214,26 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -16001,6 +16329,26 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
@@ -16020,6 +16368,26 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22574,7 +22942,8 @@
|
||||
"dailyRebuildTime",
|
||||
"buildStatus",
|
||||
"decoId",
|
||||
"decoName"
|
||||
"decoName",
|
||||
"isLocked"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -22632,6 +23001,9 @@
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"isLocked": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -23463,6 +23835,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ResetAllPlayoutsResponseModel": {
|
||||
"required": [
|
||||
"queuedPlayoutIds",
|
||||
"skippedLocked",
|
||||
"skippedUnsupported"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queuedPlayoutIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"skippedLocked": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"skippedUnsupported": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ResolutionResponseModel": {
|
||||
"required": [
|
||||
"id",
|
||||
|
||||
+22
-4
@@ -112,12 +112,21 @@ Established by issue #215 (`PlayoutController` + `ChannelController.ResetPlayout
|
||||
`[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]` to each guarded
|
||||
action. Precedent for the 409 shape: `TraktController` (its private `ConflictProblem()`). Two nuances:
|
||||
- **Fire-and-forget bulk operations don't 409** — `POST /api/playouts/reset-all` stays 202; its
|
||||
handler (`ResetAllPlayoutsHandler`) already *skips* locked playouts, matching Blazor + the handler
|
||||
semantics. Only per-id mutations 409.
|
||||
handler (`ResetAllPlayoutsHandler`) *skips* locked playouts, matching Blazor + the handler
|
||||
semantics. Only per-id mutations 409. As of #235 the handler returns a `ResetAllPlayoutsResult`
|
||||
(`QueuedPlayoutIds` / `SkippedLocked` / `SkippedUnsupported`) and the controller returns the 202
|
||||
**with a `ResetAllPlayoutsResponseModel` body** reporting what was queued vs. skipped (locked, or
|
||||
an unsupported `ExternalJson`/`None` kind) — a fire-and-forget bulk op still reports its outcome
|
||||
rather than silently swallowing skips.
|
||||
- **Surface the lock state to clients** so they can pre-disable the buttons: stamp an `IsLocked`
|
||||
boolean onto the list DTO (`PlayoutListItemResponseModel`, set from `IsPlayoutLocked` in the
|
||||
controller's list projection) rather than adding a push channel. The SPA reads it and, on a 409,
|
||||
refreshes the list to pick up the flag.
|
||||
controller's list projection) rather than adding a push channel — and (as of #235) onto the
|
||||
single-playout GET DTO (`PlayoutResponseModel.IsLocked`, set the same way in every action that
|
||||
maps it) so a client polling one playout has the same flag. The SPA reads it and, on a 409,
|
||||
refreshes to pick up the flag.
|
||||
- **Async-op success is 202, not 200** — an endpoint whose success path only *queues* a background
|
||||
rebuild returns **202 Accepted**, not 200 (#235: `POST /api/channels/{channelNumber}/playout/reset`
|
||||
queues a `BuildPlayout` → `AcceptedResult`). Reserve 200 for a synchronous durable result.
|
||||
|
||||
### 3b. Map a "queue a background job" outcome to status codes with an enum, not a `bool`
|
||||
|
||||
@@ -135,6 +144,15 @@ operation itself, not a mutation racing it). Add `[ProducesResponseType]` for 20
|
||||
`EnqueueWithTraktLock` compensating-unlock pattern (`TraktController`): if a `WriteAsync` throws
|
||||
after a successful `Lock*`, `Unlock*` in a `catch` and rethrow — one lock ⇄ exactly one release.
|
||||
|
||||
A second exemplar (issue #235 slice B), where the lock lives on the **controller** rather than in a
|
||||
handler: `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=` acquires the
|
||||
per-source collections lock (`entityLocker.LockPlexCollections()` etc.) — the lock IS the running
|
||||
collections scan, so a `false` = 409 — then `WriteAsync`es `Synchronize{X}Collections(id, ForceScan:
|
||||
true, deep)` to the scanner channel and returns **202**. `ScannerService` releases that lock in a
|
||||
`finally` when it processes the message; the controller compensating-unlocks in a `catch` if the
|
||||
enqueue throws. `POST /api/libraries/{id}/scan?deep=` similarly threads an optional `[FromQuery] bool
|
||||
deep` into `QueueLibraryScanByLibraryId(id, DeepScan)`.
|
||||
|
||||
`NotFoundError : BaseError` lives in `ErsatzTV.Core/Errors/NotFoundError.cs` — return it from a
|
||||
handler's validation when a lookup fails, so the controller-side mapping falls out for free.
|
||||
|
||||
|
||||
@@ -270,8 +270,14 @@ intentional exit ramp until phase (b) removes Blazor entirely.
|
||||
## Section 5 — Removal execution runbook (#91 phase b, Step 2/3)
|
||||
|
||||
The removal PR is **gated** — it starts only after these clear: ~~#202 (media-source write API + SPA)~~
|
||||
**DONE 2026-07-11**, #235 F9 (deep-scan + external-collections-scan API — no API today, so `Libraries.razor`
|
||||
can't be deleted yet), and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). (#204's id-carrying pattern
|
||||
**DONE 2026-07-11**, ~~#235 F9 (deep-scan + external-collections-scan API)~~ **API DONE (#235 slice B)**:
|
||||
`POST /api/libraries/{id}/scan?deep=` now threads deep-scan, and `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=`
|
||||
covers external-collections scan (the two `Libraries.razor` parity gaps) — the SPA `Libraries.razor` port can now
|
||||
proceed, and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). **Remaining SPA affordance for
|
||||
the removal PR** (API + thin `web/src/api/libraries.ts` clients — `scanLibrary(id, deep)`, `scanCollections` —
|
||||
already shipped by #235): `LibrariesScreen` currently exposes only quick-scan; the removal PR must add the
|
||||
**deep-scan** and **external-collections-scan** buttons (wiring the shipped clients) before deleting
|
||||
`Libraries.razor`, or that capability is lost. (#204's id-carrying pattern
|
||||
redirects landed 2026-07-11; its catch-all fallback that replaces `MapFallbackToPage("/_Host")` is folded
|
||||
into Step 2 below, since it can only ship when `_Host` is deleted.) When those close, the removal-PR routine
|
||||
executes, in order:
|
||||
|
||||
@@ -650,6 +650,60 @@ action of the Step 2 deletion PR merge** (not before — `main` moves until then
|
||||
|
||||
Not cut this session — `main` still carries Blazor and will advance before the removal PR.
|
||||
|
||||
## 2026-07-11 — Async-op API contract normalization + playout build observability + F9 scan endpoints (#235)
|
||||
|
||||
Reviewer#20 F7/F8/F9. Normalizes the queue-triggering `/api/*` endpoints onto one contract, closes the two
|
||||
F9 `Libraries.razor` parity gaps, and hardens the Trakt batch-lock lifecycle. Much of the F8 surface was
|
||||
**already normalized** by #232 (library scan → `QueueLibraryScanResult` 202/404/409/422) and #215 (per-id
|
||||
playout mutations + reset → 409 lock guard) — this issue finished the remaining outliers.
|
||||
|
||||
**Normalized async-op contract** (queue-triggering endpoints): **202 Accepted** = work queued; **404
|
||||
ProblemDetails** = entity missing (controller pre-check); **409 ProblemDetails** = lock held (the running
|
||||
job, or a mutation racing it — §3a/§3b); **422 ProblemDetails** = domain precondition (sync disabled /
|
||||
unsupported / start failed). Trakt was the reference implementation. Changes made:
|
||||
- `MaintenanceController.EmptyTrash` — error path **500 text/plain → 404/422 ProblemDetails** (`ToErrorResult`).
|
||||
- `MaintenanceController.CleanArtwork` — silent **200 → 202** (fire-and-forget enqueue). No SPA consumer.
|
||||
- `LibrariesController.ScanShow` — conflated **400 `{error}` → 202/404/409/422** via a new
|
||||
`QueueShowScanResult` enum (6 outcomes incl. an honest `ScanFailed`→422, distinct from `Unsupported`).
|
||||
- `ChannelController.ResetPlayout` — **200 → 202** (queue-triggering; 404/409 unchanged).
|
||||
- `PlayoutController.ResetAll` — **202 (no body) → 202 + `ResetAllPlayoutsResponseModel`** reporting
|
||||
`queuedPlayoutIds` / `skippedLocked` / `skippedUnsupported` (replaces the silent skip; still 202, still
|
||||
skips locked/ExternalJson by design per §3a — now it *reports* what it skipped).
|
||||
- `TroubleshootController.TroubleshootPlayback` — bare body-less `NotFound()` → **404/422 ProblemDetails**
|
||||
with distinguishing detail. **Status codes the SPA HLS player depends on were preserved** — verified
|
||||
`HlsPlayer.tsx` never branches on this endpoint's status (playback state comes from the separate
|
||||
`/api/troubleshoot/playback/status` poll); only the error *body* was enriched.
|
||||
|
||||
**Playout build observability**: the list endpoint (`GET /api/playouts`) already stamped `isLocked` +
|
||||
`BuildStatus` on `PlayoutListItemResponseModel` (#215); this issue adds **`isLocked` to the single-playout
|
||||
`GET /api/playouts/{id}`** (`PlayoutResponseModel`), so the detail poll surface carries the §3a lock flag
|
||||
too. No dedicated `GET /api/playouts/{id}/status` push channel was added — the flag on the existing GETs is
|
||||
the HTTP-observable substitute for Blazor's live lock event, matching the `GET /api/trakt/status` precedent.
|
||||
|
||||
**F9 parity endpoints** (the `Libraries.razor` deletion gate — #202 did NOT close these):
|
||||
- **Deep scan**: `POST /api/libraries/{id}/scan` gains `?deep=false`, threaded through
|
||||
`QueueLibraryScanByLibraryId(LibraryId, DeepScan=false)` into `ForceSynchronize{Plex,Jellyfin,Emby}LibraryById(id, deep)`
|
||||
(was hardcoded `false`). Non-breaking: existing callers omit it.
|
||||
- **External-collections scan**: new `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=false`
|
||||
on the three #202 media-source controllers, dispatching `Synchronize{X}Collections(id, ForceScan:true, deep)`.
|
||||
Each pre-checks source existence (404), acquires the per-source **collections** lock (`Lock{X}Collections()` —
|
||||
the lock *is* the running scan, so a false = **409**), then enqueues and returns 202; the controller
|
||||
compensating-unlocks in a `catch` if the enqueue throws (§3b), and `ScannerService` releases in its `finally`.
|
||||
Thin SPA clients shipped (`scanLibrary(id, deep)`, `scanCollections`); **the SPA deep-scan / collections
|
||||
buttons are the removal PR's remaining parity work** (parity doc §5).
|
||||
|
||||
**F7 Trakt batch-lock leak fix**: the global Trakt lock was released only when the *terminal* batch message
|
||||
(`Unlock: true`) was processed; a `WorkerService` shutdown/cancellation before that message leaked the lock
|
||||
permanently (subsequent Trakt ops 409 until restart — same class as #231/#233/#234). Fix: `WorkerService`
|
||||
now releases the Trakt lock in a `finally` on read-loop exit if still held. Non-vacuous regression test proven
|
||||
against an inverted-condition control.
|
||||
|
||||
**Accepted-by-design** (per the issue's decision-record ask): the worker's channels are **unbounded** and
|
||||
there is **no shutdown drain** — messages still queued at process exit are dropped. This is acceptable because
|
||||
the entity locks are **in-memory singletons that die with the process**, so a dropped message can't strand a
|
||||
lock across restarts (the F7 `finally` covers the *within-process* shutdown-break leak, which is the only way
|
||||
a lock outlives its batch while the process keeps running). Adding a bounded-channel backpressure / graceful
|
||||
drain is out of scope and would not fix a correctness bug.
|
||||
## 2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)
|
||||
|
||||
Replace-all aggregate PUTs had **no** optimistic concurrency — a stale second tab silently overwrote a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
|
||||
|
||||
155 endpoints, 240 operations.
|
||||
158 endpoints, 243 operations.
|
||||
|
||||
## Artists
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
| GET | `/api/media-sources/emby/{id}/path-replacements` | GetEmbyPathReplacements | Get an Emby source's path replacements |
|
||||
| PUT | `/api/media-sources/emby/{id}/path-replacements` | ReplaceEmbyPathReplacements | Replace an Emby source's path replacements |
|
||||
| POST | `/api/media-sources/emby/{id}/refresh-libraries` | RefreshEmbyLibraries | Refresh an Emby source's libraries |
|
||||
| POST | `/api/media-sources/emby/{id}/scan-collections` | ScanEmbyCollections | Scan an Emby source's collections |
|
||||
|
||||
## FFmpeg Profiles
|
||||
|
||||
@@ -176,6 +177,7 @@
|
||||
| GET | `/api/media-sources/jellyfin/{id}/path-replacements` | GetJellyfinPathReplacements | Get a Jellyfin source's path replacements |
|
||||
| PUT | `/api/media-sources/jellyfin/{id}/path-replacements` | ReplaceJellyfinPathReplacements | Replace a Jellyfin source's path replacements |
|
||||
| POST | `/api/media-sources/jellyfin/{id}/refresh-libraries` | RefreshJellyfinLibraries | Refresh a Jellyfin source's libraries |
|
||||
| POST | `/api/media-sources/jellyfin/{id}/scan-collections` | ScanJellyfinCollections | Scan a Jellyfin source's collections |
|
||||
|
||||
## Languages
|
||||
|
||||
@@ -295,6 +297,7 @@
|
||||
| GET | `/api/media-sources/plex/{id}/path-replacements` | GetPlexPathReplacements | Get a Plex server's path replacements |
|
||||
| PUT | `/api/media-sources/plex/{id}/path-replacements` | ReplacePlexPathReplacements | Replace a Plex server's path replacements |
|
||||
| POST | `/api/media-sources/plex/{id}/refresh-libraries` | RefreshPlexLibraries | Refresh a Plex server's libraries |
|
||||
| POST | `/api/media-sources/plex/{id}/scan-collections` | ScanPlexCollections | Scan a Plex server's collections |
|
||||
|
||||
## Rerun Collections
|
||||
|
||||
|
||||
Vendored
+6
@@ -1093,6 +1093,7 @@ export interface components {
|
||||
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
|
||||
"decoId": null | number;
|
||||
"decoName": null | string;
|
||||
"isLocked": boolean;
|
||||
};
|
||||
"PlayoutScheduleKind": "None" | "Classic" | "Block" | "Sequential" | "Scripted" | "ExternalJson";
|
||||
"PlayoutSettingsResponseModel": {
|
||||
@@ -1243,6 +1244,11 @@ export interface components {
|
||||
"selectedName": null | string;
|
||||
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"ResetAllPlayoutsResponseModel": {
|
||||
"queuedPlayoutIds": Array<number>;
|
||||
"skippedLocked": Array<number>;
|
||||
"skippedUnsupported": Array<number>;
|
||||
};
|
||||
"ResolutionResponseModel": {
|
||||
"id": number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { scanLibrary, scanShow } from './libraries';
|
||||
import { scanCollections, scanLibrary, scanShow } from './libraries';
|
||||
|
||||
function noContent(): Response {
|
||||
return new Response(null, { status: 200 });
|
||||
@@ -22,6 +22,25 @@ describe('libraries api client', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/libraries/4/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
it('scanLibrary appends ?deep=true for a deep scan', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
await scanLibrary(4, true);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/libraries/4/scan?deep=true', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
it('scanCollections POSTs to the media-source scan-collections endpoint', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
await scanCollections('jellyfin', 7);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/media-sources/jellyfin/7/scan-collections', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
it('scanCollections appends ?deep=true for a deep scan', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
await scanCollections('plex', 3, true);
|
||||
const { url } = lastCall(fetchMock);
|
||||
expect(url).toBe('/api/media-sources/plex/3/scan-collections?deep=true');
|
||||
});
|
||||
|
||||
it('scanShow POSTs the show id and deepScan flag', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
await scanShow(4, { deepScan: true, showId: 42 });
|
||||
|
||||
@@ -42,8 +42,24 @@ export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
|
||||
);
|
||||
}
|
||||
|
||||
export function scanLibrary(libraryId: number): Promise<void> {
|
||||
return request<void>(`/api/libraries/${libraryId}/scan`, { method: 'POST' });
|
||||
// Queues a library scan. `deep` requests a deep (force-metadata) scan — the API equivalent of the
|
||||
// legacy Blazor "Deep Scan Library" button (#235 F9); omit or pass false for a quick scan. Returns
|
||||
// 202 when queued, 404 (missing), 409 (already scanning), 422 (sync disabled) — see §3b.
|
||||
export function scanLibrary(libraryId: number, deep = false): Promise<void> {
|
||||
const query = deep ? '?deep=true' : '';
|
||||
return request<void>(`/api/libraries/${libraryId}/scan${query}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// Media-source families that support an external-collections scan (#235 F9). Matches the three
|
||||
// media-source controllers #202 introduced.
|
||||
export type CollectionsScanSource = 'emby' | 'jellyfin' | 'plex';
|
||||
|
||||
// Queues an external-collections scan for a Plex/Jellyfin/Emby media source — the API equivalent of
|
||||
// the legacy Blazor "Scan Collections" button (#235 F9). `deep` requests a deep scan. Returns 202
|
||||
// when queued, 404 (source missing), 409 (a collections scan is already running for that source).
|
||||
export function scanCollections(source: CollectionsScanSource, sourceId: number, deep = false): Promise<void> {
|
||||
const query = deep ? '?deep=true' : '';
|
||||
return request<void>(`/api/media-sources/${source}/${sourceId}/scan-collections${query}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export interface ScanShowParams {
|
||||
@@ -51,9 +67,11 @@ export interface ScanShowParams {
|
||||
deepScan?: boolean;
|
||||
}
|
||||
|
||||
// Queues a scan of a single show (by id) within a library. Returns 200 on success, 404 when the
|
||||
// show id doesn't exist in the library, 400 when the library doesn't support single-show
|
||||
// scanning. Body keys are `showId` and `deepScan` (see LibrariesController.ScanShowRequest).
|
||||
// Queues a scan of a single show (by id) within a library. Returns 202 when queued, 404 when the
|
||||
// show id doesn't exist in the library, 409 when a scan is already running, and 422 when the
|
||||
// library doesn't support single-show scanning / sync is disabled / the scan failed to start (all
|
||||
// error bodies are ProblemDetails; #235 normalized the old conflated 400). Body keys are `showId`
|
||||
// and `deepScan` (see LibrariesController.ScanShowRequest).
|
||||
export function scanShow(libraryId: number, params: ScanShowParams): Promise<void> {
|
||||
return request<void>(`/api/libraries/${libraryId}/scan-show`, {
|
||||
body: { deepScan: params.deepScan ?? false, showId: params.showId },
|
||||
|
||||
@@ -108,8 +108,13 @@ export function getPlayoutChannelStates(): Promise<PlayoutChannelState[]> {
|
||||
return request<PlayoutChannelState[]>('/api/channels/state');
|
||||
}
|
||||
|
||||
export function resetAllPlayouts(): Promise<void> {
|
||||
return request<void>('/api/playouts/reset-all', { method: 'POST' });
|
||||
export type ResetAllPlayoutsResult = components['schemas']['ResetAllPlayoutsResponseModel'];
|
||||
|
||||
// Queues a reset of every eligible playout. Returns 202 with a body reporting which playouts were
|
||||
// queued vs skipped (locked, or an unsupported ExternalJson/None schedule kind) — #235 replaced the
|
||||
// old silent skip. The caller may surface `skipped*` to explain why some playouts didn't reset.
|
||||
export function resetAllPlayouts(): Promise<ResetAllPlayoutsResult> {
|
||||
return request<ResetAllPlayoutsResult>('/api/playouts/reset-all', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function deletePlayout(playoutId: number): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user