@@ -44,7 +44,17 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
if (_entityLocker.LockLibrary(localLibrary.Id))
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
try
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner only unlocks when it receives the message; if the enqueue fails
|
||||
// after we acquired the lock, release it here or it is held forever.
|
||||
_entityLocker.UnlockLibrary(localLibrary.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
return ProjectToViewModel(localLibrary);
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<bool>;
|
||||
public enum QueueLibraryScanResult
|
||||
{
|
||||
Queued,
|
||||
NotFound,
|
||||
SyncDisabled,
|
||||
AlreadyScanning
|
||||
}
|
||||
|
||||
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<QueueLibraryScanResult>;
|
||||
|
||||
@@ -17,9 +17,11 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
IEntityLocker locker,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorker,
|
||||
ILogger<QueueLibraryScanByLibraryIdHandler> logger)
|
||||
: IRequestHandler<QueueLibraryScanByLibraryId, bool>
|
||||
: IRequestHandler<QueueLibraryScanByLibraryId, QueueLibraryScanResult>
|
||||
{
|
||||
public async Task<bool> Handle(QueueLibraryScanByLibraryId request, CancellationToken cancellationToken)
|
||||
public async Task<QueueLibraryScanResult> Handle(
|
||||
QueueLibraryScanByLibraryId request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
@@ -40,10 +42,17 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
if (!shouldSyncItems)
|
||||
{
|
||||
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id);
|
||||
return false;
|
||||
return QueueLibraryScanResult.SyncDisabled;
|
||||
}
|
||||
|
||||
if (locker.LockLibrary(library.Id))
|
||||
// A true from LockLibrary confers ownership of exactly one release; a false means a scan
|
||||
// is already in progress and we own no release.
|
||||
if (!locker.LockLibrary(library.Id))
|
||||
{
|
||||
return QueueLibraryScanResult.AlreadyScanning;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
logger.LogDebug("Queued library scan for library id {Id}", library.Id);
|
||||
|
||||
@@ -78,10 +87,18 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner only unlocks when it receives the message; if enqueueing fails
|
||||
// (e.g. request aborted / channel completed) after we acquired the lock, release
|
||||
// it here or it is held forever (EnqueueWithTraktLock pattern).
|
||||
locker.UnlockLibrary(library.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
return true;
|
||||
return QueueLibraryScanResult.Queued;
|
||||
}
|
||||
|
||||
return false;
|
||||
return QueueLibraryScanResult.NotFound;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,17 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
if (_entityLocker.LockLibrary(existing.Id))
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
try
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner only unlocks when it receives the message; if the enqueue fails
|
||||
// after we acquired the lock, release it here or it is held forever.
|
||||
_entityLocker.UnlockLibrary(existing.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,17 @@ public class UpdateTraktListHandler(
|
||||
|
||||
if (entityLocker.LockTrakt())
|
||||
{
|
||||
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), cancellationToken);
|
||||
try
|
||||
{
|
||||
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the background handler only unlocks when it receives the message; if the
|
||||
// enqueue fails after we acquired the lock, release it here or it is held forever.
|
||||
entityLocker.UnlockTrakt();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (traktList.PlaylistId is not null)
|
||||
|
||||
@@ -7,15 +7,20 @@ public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string
|
||||
int PlexLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
bool DeepScan { get; }
|
||||
|
||||
// When false, the ScannerService keeps the library lock held after this message so a later
|
||||
// message in the same batch (e.g. SynchronizePlexNetworks) carries the single release.
|
||||
bool Unlock { get; }
|
||||
}
|
||||
|
||||
public record SynchronizePlexLibraryByIdIfNeeded(int PlexLibraryId) : ISynchronizePlexLibraryById
|
||||
public record SynchronizePlexLibraryByIdIfNeeded(int PlexLibraryId, bool Unlock = true) : ISynchronizePlexLibraryById
|
||||
{
|
||||
public bool ForceScan => false;
|
||||
public bool DeepScan => false;
|
||||
}
|
||||
|
||||
public record ForceSynchronizePlexLibraryById(int PlexLibraryId, bool DeepScan) : ISynchronizePlexLibraryById
|
||||
public record ForceSynchronizePlexLibraryById(int PlexLibraryId, bool DeepScan, bool Unlock = true)
|
||||
: ISynchronizePlexLibraryById
|
||||
{
|
||||
public bool ForceScan => true;
|
||||
}
|
||||
|
||||
@@ -2,5 +2,9 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
// Unlock defaults true so single-message callers release the library lock as before. In the Plex
|
||||
// "Shows" scheduler batch this message runs LAST and carries the single release (the preceding
|
||||
// library message runs with Unlock: false).
|
||||
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan, bool Unlock = true)
|
||||
: IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
namespace ErsatzTV.Core.Interfaces.Locking;
|
||||
|
||||
/// <summary>
|
||||
/// Advisory "an operation on entity X is in progress" mutex layer, registered as a process-wide singleton.
|
||||
/// Each lock kind is a single exclusive slot (not reentrant or counted): a second acquire while held returns false.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ownership contract (see docs/decisions.md 2026-07-11, issue #231): every <c>Lock*</c> call returns
|
||||
/// <c>true</c> iff it transitioned the slot to locked. A <c>true</c> confers ownership of exactly one release —
|
||||
/// performed either in the acquiring scope (release in a <c>finally</c> gated on the captured return value) or by
|
||||
/// the single designated releaser it hands off to (the consumer of a message enqueued while holding the lock, with
|
||||
/// a compensating unlock if the enqueue itself throws). Callers must never release a lock they did not acquire.
|
||||
/// <c>Unlock*</c> on an already-unlocked slot is a no-op that returns <c>false</c>, raises no event, and logs a
|
||||
/// warning — the tripwire for double-release / non-owner-release bugs; it deliberately does not throw, so the
|
||||
/// primitive stays safe to call from <c>finally</c> paths.
|
||||
/// </remarks>
|
||||
public interface IEntityLocker
|
||||
{
|
||||
event EventHandler OnLibraryChanged;
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
using ErsatzTV.Infrastructure.Locking;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Locking;
|
||||
|
||||
// Verifies the atomicity + single-owner release contract from docs/decisions.md 2026-07-11 (issue #231).
|
||||
// These run against the real EntityLocker (not a mock) so the Interlocked/ConcurrentDictionary state is exercised.
|
||||
[TestFixture]
|
||||
public class EntityLockerTests
|
||||
{
|
||||
private const int ParallelCallers = 64;
|
||||
|
||||
private static EntityLocker CreateLocker() =>
|
||||
new(Substitute.For<IMediator>(), NullLogger<EntityLocker>.Instance);
|
||||
|
||||
// Every exclusive single-slot kind, adapted to a uniform bool lock/unlock/is-locked shape.
|
||||
// Library bakes in a fixed id; RemoteMediaSource a concrete media-source type. Playout is async
|
||||
// (tested separately) and so is not in this source.
|
||||
private static IEnumerable<LockKind> ExclusiveWinnerKinds()
|
||||
{
|
||||
foreach (LockKind kind in EventCountingKinds())
|
||||
{
|
||||
yield return kind;
|
||||
}
|
||||
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "RemoteMediaSource",
|
||||
Lock = l => l.LockRemoteMediaSource<PlexMediaSource>(),
|
||||
Unlock = l => l.UnlockRemoteMediaSource<PlexMediaSource>(),
|
||||
IsLocked = l => l.IsRemoteMediaSourceLocked<PlexMediaSource>()
|
||||
};
|
||||
}
|
||||
|
||||
// The kinds whose change event is a plain EventHandler, so a subscriber can count transitions.
|
||||
private static IEnumerable<LockKind> EventCountingKinds()
|
||||
{
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "Plex",
|
||||
Lock = l => l.LockPlex(),
|
||||
Unlock = l => l.UnlockPlex(),
|
||||
IsLocked = l => l.IsPlexLocked(),
|
||||
Subscribe = (l, h) => l.OnPlexChanged += h
|
||||
};
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "Trakt",
|
||||
Lock = l => l.LockTrakt(),
|
||||
Unlock = l => l.UnlockTrakt(),
|
||||
IsLocked = l => l.IsTraktLocked(),
|
||||
Subscribe = (l, h) => l.OnTraktChanged += h
|
||||
};
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "EmbyCollections",
|
||||
Lock = l => l.LockEmbyCollections(),
|
||||
Unlock = l => l.UnlockEmbyCollections(),
|
||||
IsLocked = l => l.AreEmbyCollectionsLocked(),
|
||||
Subscribe = (l, h) => l.OnEmbyCollectionsChanged += h
|
||||
};
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "JellyfinCollections",
|
||||
Lock = l => l.LockJellyfinCollections(),
|
||||
Unlock = l => l.UnlockJellyfinCollections(),
|
||||
IsLocked = l => l.AreJellyfinCollectionsLocked(),
|
||||
Subscribe = (l, h) => l.OnJellyfinCollectionsChanged += h
|
||||
};
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "PlexCollections",
|
||||
Lock = l => l.LockPlexCollections(),
|
||||
Unlock = l => l.UnlockPlexCollections(),
|
||||
IsLocked = l => l.ArePlexCollectionsLocked(),
|
||||
Subscribe = (l, h) => l.OnPlexCollectionsChanged += h
|
||||
};
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "TroubleshootingPlayback",
|
||||
Lock = l => l.LockTroubleshootingPlayback(),
|
||||
Unlock = l => l.UnlockTroubleshootingPlayback(),
|
||||
IsLocked = l => l.IsTroubleshootingPlaybackLocked(),
|
||||
Subscribe = (l, h) => l.OnTroubleshootingPlaybackChanged += h
|
||||
};
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "Library",
|
||||
Lock = l => l.LockLibrary(1),
|
||||
Unlock = l => l.UnlockLibrary(1),
|
||||
IsLocked = l => l.IsLibraryLocked(1),
|
||||
Subscribe = (l, h) => l.OnLibraryChanged += h
|
||||
};
|
||||
}
|
||||
|
||||
// Stress the acquire race over many rounds: a single-round 64-thread barrier does NOT reliably
|
||||
// collide the non-atomic check-then-set window on fast hardware (verified: it green-lit a
|
||||
// deliberately broken flag), so this hammers Lock* for thousands of rounds — each round every
|
||||
// thread races one acquire, exactly one must win, then the winner releases to reset. Any round
|
||||
// with a winner count != 1 fails the whole test. Against the atomic CAS this is invariant;
|
||||
// against a non-atomic flag some round double-acquires.
|
||||
[Test]
|
||||
[TestCaseSource(nameof(ExclusiveWinnerKinds))]
|
||||
public void Lock_ParallelCallers_ExactlyOneWinner(LockKind kind)
|
||||
{
|
||||
const int threads = 8;
|
||||
const int rounds = 20_000;
|
||||
|
||||
EntityLocker locker = CreateLocker();
|
||||
var winners = 0;
|
||||
var badRounds = 0;
|
||||
|
||||
using var startRound = new Barrier(threads);
|
||||
using var endRound = new Barrier(
|
||||
threads,
|
||||
_ =>
|
||||
{
|
||||
if (Volatile.Read(ref winners) != 1)
|
||||
{
|
||||
Interlocked.Increment(ref badRounds);
|
||||
}
|
||||
|
||||
// reset for the next round: release the (single) held lock and clear the counter
|
||||
kind.Unlock(locker);
|
||||
Volatile.Write(ref winners, 0);
|
||||
});
|
||||
|
||||
var workers = new Thread[threads];
|
||||
for (var t = 0; t < threads; t++)
|
||||
{
|
||||
workers[t] = new Thread(() =>
|
||||
{
|
||||
for (var r = 0; r < rounds; r++)
|
||||
{
|
||||
startRound.SignalAndWait();
|
||||
if (kind.Lock(locker))
|
||||
{
|
||||
Interlocked.Increment(ref winners);
|
||||
}
|
||||
|
||||
endRound.SignalAndWait();
|
||||
}
|
||||
});
|
||||
workers[t].Start();
|
||||
}
|
||||
|
||||
foreach (Thread worker in workers)
|
||||
{
|
||||
worker.Join();
|
||||
}
|
||||
|
||||
badRounds.ShouldBe(0);
|
||||
kind.IsLocked(locker).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LockPlayout_ParallelCallers_ExactlyOneWinner_PublishesOnce()
|
||||
{
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
var locker = new EntityLocker(mediator, NullLogger<EntityLocker>.Instance);
|
||||
const int playoutId = 7;
|
||||
using var barrier = new Barrier(ParallelCallers);
|
||||
|
||||
Task<bool>[] tasks = Enumerable.Range(0, ParallelCallers)
|
||||
.Select(_ => Task.Run(async () =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
return await locker.LockPlayout(playoutId);
|
||||
}))
|
||||
.ToArray();
|
||||
|
||||
bool[] results = await Task.WhenAll(tasks);
|
||||
|
||||
results.Count(r => r).ShouldBe(1);
|
||||
locker.IsPlayoutLocked(playoutId).ShouldBeTrue();
|
||||
await mediator.Received(1).Publish(
|
||||
Arg.Is<PlayoutUpdatedNotification>(n => n.PlayoutId == playoutId && n.IsLocked),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCaseSource(nameof(EventCountingKinds))]
|
||||
public void Lock_WhenAlreadyLocked_ReturnsFalse_AndFiresNoSecondEvent(LockKind kind)
|
||||
{
|
||||
EntityLocker locker = CreateLocker();
|
||||
var eventCount = 0;
|
||||
kind.Subscribe!(locker, (_, _) => eventCount++);
|
||||
|
||||
kind.Lock(locker).ShouldBeTrue();
|
||||
kind.Lock(locker).ShouldBeFalse();
|
||||
|
||||
kind.IsLocked(locker).ShouldBeTrue();
|
||||
eventCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCaseSource(nameof(EventCountingKinds))]
|
||||
public void Unlock_WhenNotLocked_ReturnsFalse_AndFiresNoEvent(LockKind kind)
|
||||
{
|
||||
EntityLocker locker = CreateLocker();
|
||||
var eventCount = 0;
|
||||
kind.Subscribe!(locker, (_, _) => eventCount++);
|
||||
|
||||
kind.Unlock(locker).ShouldBeFalse();
|
||||
|
||||
eventCount.ShouldBe(0);
|
||||
kind.IsLocked(locker).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCaseSource(nameof(EventCountingKinds))]
|
||||
public void Unlock_AfterLock_ReturnsTrue_ThenSecondUnlockReturnsFalse(LockKind kind)
|
||||
{
|
||||
EntityLocker locker = CreateLocker();
|
||||
var eventCount = 0;
|
||||
kind.Subscribe!(locker, (_, _) => eventCount++);
|
||||
|
||||
kind.Lock(locker).ShouldBeTrue();
|
||||
kind.Unlock(locker).ShouldBeTrue();
|
||||
kind.Unlock(locker).ShouldBeFalse();
|
||||
|
||||
kind.IsLocked(locker).ShouldBeFalse();
|
||||
eventCount.ShouldBe(2); // one lock transition + one unlock transition, the no-op unlock fires nothing
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCaseSource(nameof(EventCountingKinds))]
|
||||
public async Task ParallelUnlock_AfterSingleLock_ExactlyOneReleaser(LockKind kind)
|
||||
{
|
||||
EntityLocker locker = CreateLocker();
|
||||
kind.Lock(locker).ShouldBeTrue();
|
||||
|
||||
var unlockEvents = 0;
|
||||
kind.Subscribe!(locker, (_, _) => Interlocked.Increment(ref unlockEvents));
|
||||
using var barrier = new Barrier(ParallelCallers);
|
||||
|
||||
Task<bool>[] tasks = Enumerable.Range(0, ParallelCallers)
|
||||
.Select(_ => Task.Run(() =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
return kind.Unlock(locker);
|
||||
}))
|
||||
.ToArray();
|
||||
|
||||
bool[] results = await Task.WhenAll(tasks);
|
||||
|
||||
results.Count(r => r).ShouldBe(1);
|
||||
kind.IsLocked(locker).ShouldBeFalse();
|
||||
unlockEvents.ShouldBe(1);
|
||||
}
|
||||
|
||||
public sealed class LockKind
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required Func<EntityLocker, bool> Lock { get; init; }
|
||||
public required Func<EntityLocker, bool> Unlock { get; init; }
|
||||
public required Func<EntityLocker, bool> IsLocked { get; init; }
|
||||
|
||||
// null for kinds whose change event is not a plain EventHandler (RemoteMediaSource is EventHandler<Type>).
|
||||
public Action<EntityLocker, EventHandler>? Subscribe { get; init; }
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,25 @@
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Locking;
|
||||
|
||||
public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
public class EntityLocker(IMediator mediator, ILogger<EntityLocker> logger) : IEntityLocker
|
||||
{
|
||||
private readonly ConcurrentDictionary<int, byte> _lockedLibraries = new();
|
||||
private readonly ConcurrentDictionary<int, byte> _lockedPlayouts = new();
|
||||
private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes = new();
|
||||
private bool _embyCollections;
|
||||
private bool _jellyfinCollections;
|
||||
private bool _plex;
|
||||
private bool _plexCollections;
|
||||
private bool _trakt;
|
||||
private bool _troubleshootingPlayback;
|
||||
|
||||
// 0 = unlocked, 1 = locked. Mutated only via Interlocked.CompareExchange so the caller that wins
|
||||
// the 0 -> 1 transition is the sole owner and the only one that fires the change event
|
||||
// (see docs/decisions.md 2026-07-11, issue #231).
|
||||
private int _embyCollections;
|
||||
private int _jellyfinCollections;
|
||||
private int _plex;
|
||||
private int _plexCollections;
|
||||
private int _trakt;
|
||||
private int _troubleshootingPlayback;
|
||||
|
||||
public event EventHandler OnLibraryChanged;
|
||||
public event EventHandler OnPlexChanged;
|
||||
@@ -28,7 +33,7 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool LockLibrary(int libraryId)
|
||||
{
|
||||
if (!_lockedLibraries.ContainsKey(libraryId) && _lockedLibraries.TryAdd(libraryId, 0))
|
||||
if (_lockedLibraries.TryAdd(libraryId, 0))
|
||||
{
|
||||
OnLibraryChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
@@ -45,6 +50,9 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockLibrary called for library {LibraryId} that was not locked (double-release or non-owner release)",
|
||||
libraryId);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -53,9 +61,8 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool LockPlex()
|
||||
{
|
||||
if (!_plex)
|
||||
if (Interlocked.CompareExchange(ref _plex, 1, 0) == 0)
|
||||
{
|
||||
_plex = true;
|
||||
OnPlexChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
@@ -65,17 +72,17 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool UnlockPlex()
|
||||
{
|
||||
if (_plex)
|
||||
if (Interlocked.CompareExchange(ref _plex, 0, 1) == 1)
|
||||
{
|
||||
_plex = false;
|
||||
OnPlexChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning("UnlockPlex called but Plex was not locked (double-release or non-owner release)");
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsPlexLocked() => _plex;
|
||||
public bool IsPlexLocked() => Volatile.Read(ref _plex) == 1;
|
||||
|
||||
public bool IsRemoteMediaSourceLocked<TMediaSource>() =>
|
||||
_lockedRemoteMediaSourceTypes.ContainsKey(typeof(TMediaSource));
|
||||
@@ -84,8 +91,7 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
{
|
||||
Type mediaSourceType = typeof(TMediaSource);
|
||||
|
||||
if (!_lockedRemoteMediaSourceTypes.ContainsKey(mediaSourceType) &&
|
||||
_lockedRemoteMediaSourceTypes.TryAdd(mediaSourceType, 0))
|
||||
if (_lockedRemoteMediaSourceTypes.TryAdd(mediaSourceType, 0))
|
||||
{
|
||||
OnRemoteMediaSourceChanged?.Invoke(this, mediaSourceType);
|
||||
return true;
|
||||
@@ -104,14 +110,16 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockRemoteMediaSource called for {MediaSourceType} that was not locked (double-release or non-owner release)",
|
||||
mediaSourceType.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool LockTrakt()
|
||||
{
|
||||
if (!_trakt)
|
||||
if (Interlocked.CompareExchange(ref _trakt, 1, 0) == 0)
|
||||
{
|
||||
_trakt = true;
|
||||
OnTraktChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
@@ -121,23 +129,22 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool UnlockTrakt()
|
||||
{
|
||||
if (_trakt)
|
||||
if (Interlocked.CompareExchange(ref _trakt, 0, 1) == 1)
|
||||
{
|
||||
_trakt = false;
|
||||
OnTraktChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning("UnlockTrakt called but Trakt was not locked (double-release or non-owner release)");
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsTraktLocked() => _trakt;
|
||||
public bool IsTraktLocked() => Volatile.Read(ref _trakt) == 1;
|
||||
|
||||
public bool LockEmbyCollections()
|
||||
{
|
||||
if (!_embyCollections)
|
||||
if (Interlocked.CompareExchange(ref _embyCollections, 1, 0) == 0)
|
||||
{
|
||||
_embyCollections = true;
|
||||
OnEmbyCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
@@ -147,23 +154,23 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool UnlockEmbyCollections()
|
||||
{
|
||||
if (_embyCollections)
|
||||
if (Interlocked.CompareExchange(ref _embyCollections, 0, 1) == 1)
|
||||
{
|
||||
_embyCollections = false;
|
||||
OnEmbyCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockEmbyCollections called but Emby collections were not locked (double-release or non-owner release)");
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool AreEmbyCollectionsLocked() => _embyCollections;
|
||||
public bool AreEmbyCollectionsLocked() => Volatile.Read(ref _embyCollections) == 1;
|
||||
|
||||
public bool LockJellyfinCollections()
|
||||
{
|
||||
if (!_jellyfinCollections)
|
||||
if (Interlocked.CompareExchange(ref _jellyfinCollections, 1, 0) == 0)
|
||||
{
|
||||
_jellyfinCollections = true;
|
||||
OnJellyfinCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
@@ -173,23 +180,23 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool UnlockJellyfinCollections()
|
||||
{
|
||||
if (_jellyfinCollections)
|
||||
if (Interlocked.CompareExchange(ref _jellyfinCollections, 0, 1) == 1)
|
||||
{
|
||||
_jellyfinCollections = false;
|
||||
OnJellyfinCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockJellyfinCollections called but Jellyfin collections were not locked (double-release or non-owner release)");
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool AreJellyfinCollectionsLocked() => _jellyfinCollections;
|
||||
public bool AreJellyfinCollectionsLocked() => Volatile.Read(ref _jellyfinCollections) == 1;
|
||||
|
||||
public bool LockPlexCollections()
|
||||
{
|
||||
if (!_plexCollections)
|
||||
if (Interlocked.CompareExchange(ref _plexCollections, 1, 0) == 0)
|
||||
{
|
||||
_plexCollections = true;
|
||||
OnPlexCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
@@ -199,21 +206,22 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool UnlockPlexCollections()
|
||||
{
|
||||
if (_plexCollections)
|
||||
if (Interlocked.CompareExchange(ref _plexCollections, 0, 1) == 1)
|
||||
{
|
||||
_plexCollections = false;
|
||||
OnPlexCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockPlexCollections called but Plex collections were not locked (double-release or non-owner release)");
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ArePlexCollectionsLocked() => _plexCollections;
|
||||
public bool ArePlexCollectionsLocked() => Volatile.Read(ref _plexCollections) == 1;
|
||||
|
||||
public async Task<bool> LockPlayout(int playoutId)
|
||||
{
|
||||
if (!_lockedPlayouts.ContainsKey(playoutId) && _lockedPlayouts.TryAdd(playoutId, 0))
|
||||
if (_lockedPlayouts.TryAdd(playoutId, 0))
|
||||
{
|
||||
await mediator.Publish(new PlayoutUpdatedNotification(playoutId, true));
|
||||
return true;
|
||||
@@ -230,6 +238,9 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockPlayout called for playout {PlayoutId} that was not locked (double-release or non-owner release)",
|
||||
playoutId);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -237,9 +248,8 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool LockTroubleshootingPlayback()
|
||||
{
|
||||
if (!_troubleshootingPlayback)
|
||||
if (Interlocked.CompareExchange(ref _troubleshootingPlayback, 1, 0) == 0)
|
||||
{
|
||||
_troubleshootingPlayback = true;
|
||||
OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
@@ -249,15 +259,16 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool UnlockTroubleshootingPlayback()
|
||||
{
|
||||
if (_troubleshootingPlayback)
|
||||
if (Interlocked.CompareExchange(ref _troubleshootingPlayback, 0, 1) == 1)
|
||||
{
|
||||
_troubleshootingPlayback = false;
|
||||
OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockTroubleshootingPlayback called but troubleshooting playback was not locked (double-release or non-owner release)");
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsTroubleshootingPlaybackLocked() => _troubleshootingPlayback;
|
||||
public bool IsTroubleshootingPlaybackLocked() => Volatile.Read(ref _troubleshootingPlayback) == 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using ThreadingChannel = System.Threading.Channels.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Libraries;
|
||||
|
||||
[TestFixture]
|
||||
public class QueueLibraryScanByLibraryIdHandlerTests
|
||||
{
|
||||
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>();
|
||||
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
||||
|
||||
QueueLibraryScanResult result = await handler.Handle(
|
||||
new QueueLibraryScanByLibraryId(9999),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueLibraryScanResult.NotFound);
|
||||
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_SyncDisabled_When_Item_Sync_Off()
|
||||
{
|
||||
int libraryId = await SeedSyncDisabledPlexLibrary();
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
||||
|
||||
QueueLibraryScanResult result = await handler.Handle(
|
||||
new QueueLibraryScanByLibraryId(libraryId),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueLibraryScanResult.SyncDisabled);
|
||||
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Queue_And_Enqueue_When_Lock_Acquired()
|
||||
{
|
||||
int libraryId = await SeedLocalLibrary();
|
||||
|
||||
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),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueLibraryScanResult.Queued);
|
||||
locker.Received(1).LockLibrary(libraryId);
|
||||
locker.DidNotReceive().UnlockLibrary(Arg.Any<int>());
|
||||
channel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
request.ShouldBeOfType<ForceScanLocalLibrary>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_AlreadyScanning_When_Lock_Not_Acquired()
|
||||
{
|
||||
int libraryId = await SeedLocalLibrary();
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(libraryId).Returns(false);
|
||||
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
||||
|
||||
QueueLibraryScanResult result = await handler.Handle(
|
||||
new QueueLibraryScanByLibraryId(libraryId),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueLibraryScanResult.AlreadyScanning);
|
||||
channel.Reader.TryRead(out _).ShouldBeFalse();
|
||||
locker.DidNotReceive().UnlockLibrary(Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Two_Concurrent_Scans_Should_Be_Queued_Then_AlreadyScanning()
|
||||
{
|
||||
int libraryId = await SeedLocalLibrary();
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
// first acquire wins, second loses (a scan is already in progress)
|
||||
locker.LockLibrary(libraryId).Returns(true, false);
|
||||
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
||||
|
||||
QueueLibraryScanResult first = await handler.Handle(
|
||||
new QueueLibraryScanByLibraryId(libraryId),
|
||||
CancellationToken.None);
|
||||
QueueLibraryScanResult second = await handler.Handle(
|
||||
new QueueLibraryScanByLibraryId(libraryId),
|
||||
CancellationToken.None);
|
||||
|
||||
first.ShouldBe(QueueLibraryScanResult.Queued);
|
||||
second.ShouldBe(QueueLibraryScanResult.AlreadyScanning);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Unlock_And_Rethrow_When_Enqueue_Throws()
|
||||
{
|
||||
int libraryId = await SeedLocalLibrary();
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(libraryId).Returns(true);
|
||||
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
// a completed writer makes WriteAsync throw ChannelClosedException, simulating a failed enqueue
|
||||
channel.Writer.Complete();
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
||||
|
||||
await Should.ThrowAsync<ChannelClosedException>(() =>
|
||||
handler.Handle(new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None));
|
||||
|
||||
locker.Received(1).UnlockLibrary(libraryId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Unlock_When_Enqueue_Canceled()
|
||||
{
|
||||
int libraryId = await SeedLocalLibrary();
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(libraryId).Returns(true);
|
||||
|
||||
// a writer whose enqueue is aborted (e.g. request cancelled after the lock was taken)
|
||||
var writer = Substitute.For<ChannelWriter<IScannerBackgroundServiceRequest>>();
|
||||
writer.WriteAsync(Arg.Any<IScannerBackgroundServiceRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromException(new OperationCanceledException()));
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, writer);
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(() =>
|
||||
handler.Handle(new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None));
|
||||
|
||||
locker.Received(1).UnlockLibrary(libraryId);
|
||||
}
|
||||
|
||||
private QueueLibraryScanByLibraryIdHandler CreateHandler(
|
||||
IEntityLocker locker,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> writer) =>
|
||||
new(
|
||||
_db.Factory,
|
||||
locker,
|
||||
writer,
|
||||
NullLogger<QueueLibraryScanByLibraryIdHandler>.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> SeedSyncDisabledPlexLibrary()
|
||||
{
|
||||
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 Movies",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Key = "1",
|
||||
ShouldSyncItems = false,
|
||||
Paths = []
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.PlexMediaSources.AddAsync(source);
|
||||
await context.SaveChangesAsync();
|
||||
return source.Libraries[0].Id;
|
||||
}
|
||||
}
|
||||
@@ -95,4 +95,54 @@ public class LibrariesControllerTests
|
||||
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Return_202_When_Queued()
|
||||
{
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.Queued);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<QueueLibraryScanByLibraryId>(r => r.LibraryId == 7),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Return_404_When_NotFound()
|
||||
{
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.NotFound);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Return_409_When_AlreadyScanning()
|
||||
{
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.AlreadyScanning);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Return_422_When_SyncDisabled()
|
||||
{
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.SyncDisabled);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,9 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/trakt/lists/{id}", "put", "422")]
|
||||
[TestCase("/api/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")]
|
||||
[TestCase("/api/libraries/{id}/scan-show", "post", "404")]
|
||||
[TestCase("/api/libraries/{id}/scan", "post", "404")]
|
||||
[TestCase("/api/libraries/{id}/scan", "post", "409")]
|
||||
[TestCase("/api/libraries/{id}/scan", "post", "422")]
|
||||
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
|
||||
string path,
|
||||
string method,
|
||||
|
||||
@@ -22,10 +22,29 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
|
||||
[HttpPost("/api/libraries/{id:int}/scan")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan library")]
|
||||
public async Task<IActionResult> ScanLibrary(int id) =>
|
||||
await mediator.Send(new QueueLibraryScanByLibraryId(id))
|
||||
? new OkResult()
|
||||
: new NotFoundResult();
|
||||
[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)
|
||||
{
|
||||
QueueLibraryScanResult result = await mediator.Send(new QueueLibraryScanByLibraryId(id), cancellationToken);
|
||||
return result switch
|
||||
{
|
||||
QueueLibraryScanResult.Queued => new AcceptedResult(),
|
||||
QueueLibraryScanResult.AlreadyScanning => ApiResults.ConflictProblem(
|
||||
"Library scan in progress",
|
||||
$"A scan for library {id} is already in progress."),
|
||||
QueueLibraryScanResult.SyncDisabled => new UnprocessableEntityObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status422UnprocessableEntity,
|
||||
Title = "Library sync is disabled",
|
||||
Detail = $"Item sync is disabled for library {id}."
|
||||
}),
|
||||
_ => ApiResults.NotFoundProblem($"Library {id} does not exist.")
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("/api/libraries/{id:int}/scan-show")]
|
||||
[Tags("Libraries")]
|
||||
|
||||
+207
-163
@@ -104,31 +104,36 @@ public class ScannerService : BackgroundService
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
||||
|
||||
Either<BaseError, string> scanResult = await mediator.Send(request, cancellationToken);
|
||||
scanResult.BiIter(
|
||||
name => _logger.LogDebug(
|
||||
"Done scanning local library {Library}",
|
||||
name),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for local library {LibraryId} at this time",
|
||||
request.LibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to scan local library {LibraryId}: {Error}",
|
||||
request.LibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.IsLibraryLocked(request.LibraryId))
|
||||
try
|
||||
{
|
||||
entityLocker.UnlockLibrary(request.LibraryId);
|
||||
Either<BaseError, string> scanResult = await mediator.Send(request, cancellationToken);
|
||||
scanResult.BiIter(
|
||||
name => _logger.LogDebug(
|
||||
"Done scanning local library {Library}",
|
||||
name),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for local library {LibraryId} at this time",
|
||||
request.LibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to scan local library {LibraryId}: {Error}",
|
||||
request.LibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (entityLocker.IsLibraryLocked(request.LibraryId))
|
||||
{
|
||||
entityLocker.UnlockLibrary(request.LibraryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,29 +161,36 @@ public class ScannerService : BackgroundService
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
||||
|
||||
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
name => _logger.LogDebug("Done synchronizing plex library {Name}", name),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for plex library {LibraryId} at this time",
|
||||
request.PlexLibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize plex library {LibraryId}: {Error}",
|
||||
request.PlexLibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.IsLibraryLocked(request.PlexLibraryId))
|
||||
try
|
||||
{
|
||||
entityLocker.UnlockLibrary(request.PlexLibraryId);
|
||||
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
name => _logger.LogDebug("Done synchronizing plex library {Name}", name),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for plex library {LibraryId} at this time",
|
||||
request.PlexLibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize plex library {LibraryId}: {Error}",
|
||||
request.PlexLibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
// request.Unlock is false when a later message in the same batch (SynchronizePlexNetworks)
|
||||
// owns the single release for this library lock.
|
||||
if (request.Unlock && entityLocker.IsLibraryLocked(request.PlexLibraryId))
|
||||
{
|
||||
entityLocker.UnlockLibrary(request.PlexLibraryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,24 +202,29 @@ public class ScannerService : BackgroundService
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
_ => _logger.LogDebug("Done synchronizing plex collections"),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug("Scan is not required for plex collections at this time");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Unable to synchronize plex collections: {Error}", error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.ArePlexCollectionsLocked())
|
||||
try
|
||||
{
|
||||
entityLocker.UnlockPlexCollections();
|
||||
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
_ => _logger.LogDebug("Done synchronizing plex collections"),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug("Scan is not required for plex collections at this time");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Unable to synchronize plex collections: {Error}", error.Value);
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (entityLocker.ArePlexCollectionsLocked())
|
||||
{
|
||||
entityLocker.UnlockPlexCollections();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,29 +236,36 @@ public class ScannerService : BackgroundService
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
_ => _logger.LogDebug("Done synchronizing plex networks for library {LibraryId}", request.PlexLibraryId),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for plex networks in library {LibraryId} at this time",
|
||||
request.PlexLibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize plex networks for library {LibraryId}: {Error}",
|
||||
request.PlexLibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.IsLibraryLocked(request.PlexLibraryId))
|
||||
try
|
||||
{
|
||||
entityLocker.UnlockLibrary(request.PlexLibraryId);
|
||||
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
_ => _logger.LogDebug(
|
||||
"Done synchronizing plex networks for library {LibraryId}",
|
||||
request.PlexLibraryId),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for plex networks in library {LibraryId} at this time",
|
||||
request.PlexLibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize plex networks for library {LibraryId}: {Error}",
|
||||
request.PlexLibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (request.Unlock && entityLocker.IsLibraryLocked(request.PlexLibraryId))
|
||||
{
|
||||
entityLocker.UnlockLibrary(request.PlexLibraryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,29 +293,34 @@ public class ScannerService : BackgroundService
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
||||
|
||||
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
name => _logger.LogDebug("Done synchronizing jellyfin library {Name}", name),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for jellyfin library {LibraryId} at this time",
|
||||
request.JellyfinLibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize jellyfin library {LibraryId}: {Error}",
|
||||
request.JellyfinLibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.IsLibraryLocked(request.JellyfinLibraryId))
|
||||
try
|
||||
{
|
||||
entityLocker.UnlockLibrary(request.JellyfinLibraryId);
|
||||
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
name => _logger.LogDebug("Done synchronizing jellyfin library {Name}", name),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for jellyfin library {LibraryId} at this time",
|
||||
request.JellyfinLibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize jellyfin library {LibraryId}: {Error}",
|
||||
request.JellyfinLibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (entityLocker.IsLibraryLocked(request.JellyfinLibraryId))
|
||||
{
|
||||
entityLocker.UnlockLibrary(request.JellyfinLibraryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,24 +332,29 @@ public class ScannerService : BackgroundService
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
_ => _logger.LogDebug("Done synchronizing jellyfin collections"),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug("Scan is not required for jellyfin collections at this time");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Unable to synchronize jellyfin collections: {Error}", error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.AreJellyfinCollectionsLocked())
|
||||
try
|
||||
{
|
||||
entityLocker.UnlockJellyfinCollections();
|
||||
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
_ => _logger.LogDebug("Done synchronizing jellyfin collections"),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug("Scan is not required for jellyfin collections at this time");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Unable to synchronize jellyfin collections: {Error}", error.Value);
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (entityLocker.AreJellyfinCollectionsLocked())
|
||||
{
|
||||
entityLocker.UnlockJellyfinCollections();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,29 +380,34 @@ public class ScannerService : BackgroundService
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
||||
|
||||
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
name => _logger.LogDebug("Done synchronizing emby library {Name}", name),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for emby library {LibraryId} at this time",
|
||||
request.EmbyLibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize emby library {LibraryId}: {Error}",
|
||||
request.EmbyLibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.IsLibraryLocked(request.EmbyLibraryId))
|
||||
try
|
||||
{
|
||||
entityLocker.UnlockLibrary(request.EmbyLibraryId);
|
||||
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
name => _logger.LogDebug("Done synchronizing emby library {Name}", name),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for emby library {LibraryId} at this time",
|
||||
request.EmbyLibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize emby library {LibraryId}: {Error}",
|
||||
request.EmbyLibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (entityLocker.IsLibraryLocked(request.EmbyLibraryId))
|
||||
{
|
||||
entityLocker.UnlockLibrary(request.EmbyLibraryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,24 +419,29 @@ public class ScannerService : BackgroundService
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
_ => _logger.LogDebug("Done synchronizing emby collections"),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug("Scan is not required for emby collections at this time");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Unable to synchronize emby collections: {Error}", error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.AreEmbyCollectionsLocked())
|
||||
try
|
||||
{
|
||||
entityLocker.UnlockEmbyCollections();
|
||||
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
_ => _logger.LogDebug("Done synchronizing emby collections"),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug("Scan is not required for emby collections at this time");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Unable to synchronize emby collections: {Error}", error.Value);
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (entityLocker.AreEmbyCollectionsLocked())
|
||||
{
|
||||
entityLocker.UnlockEmbyCollections();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +211,16 @@ public class SchedulerService : BackgroundService
|
||||
{
|
||||
if (_entityLocker.LockLibrary(libraryId))
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ScanLocalLibraryIfNeeded(libraryId), cancellationToken);
|
||||
try
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ScanLocalLibraryIfNeeded(libraryId), cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the message that would release this lock never got enqueued - release it here
|
||||
_entityLocker.UnlockLibrary(libraryId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,15 +238,29 @@ public class SchedulerService : BackgroundService
|
||||
|
||||
if (_entityLocker.LockLibrary(library.Id))
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexLibraryByIdIfNeeded(library.Id),
|
||||
cancellationToken);
|
||||
// one LockLibrary ⇄ one release: when a networks scan follows, it (the last message)
|
||||
// carries the release; the library message runs with Unlock: false.
|
||||
bool networksFollow = library.MediaKind is LibraryMediaKind.Shows;
|
||||
|
||||
if (library.MediaKind is LibraryMediaKind.Shows)
|
||||
try
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexNetworks(library.Id, false),
|
||||
new SynchronizePlexLibraryByIdIfNeeded(library.Id, Unlock: !networksFollow),
|
||||
cancellationToken);
|
||||
|
||||
if (networksFollow)
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexNetworks(library.Id, false),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// if enqueueing the batch fails partway (the library message carries Unlock: false,
|
||||
// so the not-yet-enqueued networks message was the sole releaser), release here
|
||||
_entityLocker.UnlockLibrary(library.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,9 +286,17 @@ public class SchedulerService : BackgroundService
|
||||
|
||||
if (_entityLocker.LockLibrary(library.Id))
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeJellyfinLibraryByIdIfNeeded(library.Id),
|
||||
cancellationToken);
|
||||
try
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeJellyfinLibraryByIdIfNeeded(library.Id),
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_entityLocker.UnlockLibrary(library.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,9 +321,17 @@ public class SchedulerService : BackgroundService
|
||||
|
||||
if (_entityLocker.LockLibrary(library.Id))
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeEmbyLibraryByIdIfNeeded(library.Id),
|
||||
cancellationToken);
|
||||
try
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeEmbyLibraryByIdIfNeeded(library.Id),
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_entityLocker.UnlockLibrary(library.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5416,8 +5416,68 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,22 @@ action. Precedent for the 409 shape: `TraktController` (its private `ConflictPro
|
||||
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.
|
||||
|
||||
### 3b. Map a "queue a background job" outcome to status codes with an enum, not a `bool`
|
||||
|
||||
When an endpoint *starts* a background operation guarded by an `IEntityLocker` lock, return an
|
||||
**outcome enum from the handler** and map it in the controller — don't collapse distinct outcomes
|
||||
into a lying `bool`/200. Exemplar (issue #232): `QueueLibraryScanByLibraryId` →
|
||||
`QueueLibraryScanResult { Queued | NotFound | SyncDisabled | AlreadyScanning }`, mapped by
|
||||
`LibrariesController.ScanLibrary` to **202** (`AcceptedResult`, queued), **404**
|
||||
(`ApiResults.NotFoundProblem`), **422** (`UnprocessableEntityObjectResult` + `ProblemDetails`, a
|
||||
domain precondition such as sync-disabled), and **409** (`ApiResults.ConflictProblem`, the lock is
|
||||
already held = already scanning). Here the acquired lock **is** the running job, so
|
||||
`LockLibrary(id) == false` means "already scanning" → 409 (a variant of §3a where the lock is the
|
||||
operation itself, not a mutation racing it). Add `[ProducesResponseType]` for 202/404/409/422 and
|
||||
`typeof(ProblemDetails)` on the error ones. **Guard the lock→enqueue** with the
|
||||
`EnqueueWithTraktLock` compensating-unlock pattern (`TraktController`): if a `WriteAsync` throws
|
||||
after a successful `Lock*`, `Unlock*` in a `catch` and rethrow — one lock ⇄ exactly one release.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -399,3 +399,37 @@ Every new issue gets an explicit end-of-session triage verdict — gate-blocker
|
||||
slot) or backlog (label only) — so review findings adjust the queue only through that step and
|
||||
the arc doesn't drift. The handoff file keeps only the **static kickoff prompt** and the
|
||||
**append-only Lessons lore** (per-session prompts are gone; task context lives in issue bodies).
|
||||
|
||||
## 2026-07-11 — EntityLocker: atomic flags + single-owner release discipline, no owner tokens (#231)
|
||||
|
||||
`EntityLocker` (process-wide singleton, `ErsatzTV.Infrastructure/Locking/EntityLocker.cs`) is the
|
||||
advisory "operation on entity X is in progress" mutex layer. Adversarial review (#20/F5, →#231)
|
||||
found three defects: six plain-`bool` flags with a non-atomic check-then-set (two threads could both
|
||||
acquire and both return `true`), tokenless `Unlock*` letting any caller release another owner's lock
|
||||
(e.g. `BuildPlayoutHandler` ignored `LockPlayout`'s return then unconditionally unlocked in
|
||||
`finally`), and one batch taking a single `LockLibrary` released after the *first* of two enqueued
|
||||
scan units (so the second ran unlocked).
|
||||
|
||||
**Model chosen: tokenless atomic flag + documented single-owner release discipline.** The six bools
|
||||
became `int`s guarded by `Interlocked.CompareExchange` (0/1), so `Lock*`'s `bool` return is now a
|
||||
reliable "this call won the transition" signal and the change event fires exactly once per
|
||||
transition. The contract (XML-doc'd on `IEntityLocker`): a `true` from `Lock*` confers ownership of
|
||||
exactly one release — performed either in the acquiring scope (`finally`, gated on the captured
|
||||
bool) or by the single designated releaser the acquirer hands off to (the consumer of the message
|
||||
enqueued while holding the lock, with a compensating unlock if the enqueue throws — the established
|
||||
`TraktController.EnqueueWithTraktLock` / scheduler `unlock: last` tail pattern). Callers must never
|
||||
release a lock they did not acquire. `Unlock*` on an already-unlocked slot returns `false`, fires no
|
||||
event, and logs a Warning — the loud tripwire for double-release bugs; it does not throw or
|
||||
`Debug.Assert`, because an advisory flag must stay safe to release in `finally` paths. The three
|
||||
`ConcurrentDictionary`-backed kinds (Library/Playout/RemoteMediaSource) were already atomic
|
||||
(`TryAdd`/`TryRemove`) and kept their semantics (the redundant `ContainsKey` pre-checks were dropped
|
||||
as tidy-up); the interface signature is unchanged across its ~40 call sites.
|
||||
|
||||
**Rejected:** owner tokens/leases — the acquirer and releaser for Library/Trakt/Plex/Collections
|
||||
locks are different code correlated only by entity id across in-memory `Channel<T>` queues, so a
|
||||
token would have to travel inside ~8 background-request message types for a defect that discipline
|
||||
plus the now-trustworthy atomic return value already prevents. Counted/reentrant locks — wrong
|
||||
semantics: these are exclusive in-progress flags; two holders is the failure mode, not a feature.
|
||||
Call-site fixes this model prescribes land separately: #232 (scan lifecycle — enqueue only after a
|
||||
successful lock, compensating unlock on enqueue failure, one release per acquisition in batches) and
|
||||
#234 (BuildPlayout/subtitles gate their `finally` unlock on the captured acquire result).
|
||||
|
||||
+92
-44
@@ -1249,24 +1249,21 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(clearCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('re-enables the scan button and stops polling when a queued scan never appears (grace window expiry)', async () => {
|
||||
const intervalHandlers: Array<() => void> = [];
|
||||
let clearCount = 0;
|
||||
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
|
||||
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
|
||||
intervalHandlers.push(handler as () => void);
|
||||
}
|
||||
return intervalHandlers.length;
|
||||
});
|
||||
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
|
||||
clearCount += 1;
|
||||
});
|
||||
|
||||
it('reconciles a 409 "already scanning" against scan-status without an error toast (#232)', async () => {
|
||||
// POST /scan now returns 409 when a scan is already in progress. That is benign - the SPA
|
||||
// must NOT show an error; it reconciles against scan-status (which reports the active scan)
|
||||
// and keeps the button disabled while that scan runs.
|
||||
mockDashboardApi({
|
||||
libraryScanStatuses: [],
|
||||
libraryScanStatusSequence: [
|
||||
[], // initial screen load: nothing active yet
|
||||
[{ libraryId: 31, percent: 0.3 }] // post-409 reconcile fetch: the in-progress scan
|
||||
],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
]
|
||||
],
|
||||
mutationFailures: {
|
||||
'/api/libraries/31/scan': { status: 409 }
|
||||
}
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@@ -1279,39 +1276,57 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
expect(intervalHandlers.length).toBeGreaterThan(0);
|
||||
|
||||
// The id never shows up in scan-status. It survives a couple of ticks...
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(3);
|
||||
// The reconcile fetch surfaces the already-running scan; the button stays disabled...
|
||||
expect(await screen.findByText('30%')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
|
||||
// ...but expires once the grace window runs out, freeing the button and the poll.
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
|
||||
expect(clearCount).toBeGreaterThan(0);
|
||||
// ...and no error toast is shown for the benign conflict.
|
||||
expect(screen.queryByText(/Request failed/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Unable to scan/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-enables the scan button and stops polling when scan-status errors repeatedly after a trigger (grace window on error)', async () => {
|
||||
it('surfaces a 422 "sync disabled" scan rejection and leaves the button enabled (#232)', async () => {
|
||||
// POST /scan now returns 422 when item sync is disabled for the library. Nothing was queued,
|
||||
// so the button must not be stuck disabled and the error must be surfaced.
|
||||
mockDashboardApi({
|
||||
libraryScanStatuses: [],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
],
|
||||
mutationFailures: {
|
||||
'/api/libraries/31/scan': { detail: 'Item sync is disabled for library 31.', status: 422 }
|
||||
}
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
expect(await screen.findByText('Item sync is disabled for library 31.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('re-enables the scan button after the grace window if a 202 scan never appears active (#232)', async () => {
|
||||
// A 202 genuinely queues a (forced) scan, but a short/empty library can finish before the next
|
||||
// 10s poll ever observes it in scan-status - or the scanner may never register it. The optimistic
|
||||
// "pending" flag must not wedge the button disabled forever: it expires after PENDING_GRACE_TICKS
|
||||
// polls that never see the library active, then the button re-enables.
|
||||
const intervalHandlers: Array<() => void> = [];
|
||||
let clearCount = 0;
|
||||
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
|
||||
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
|
||||
intervalHandlers.push(handler as () => void);
|
||||
}
|
||||
return intervalHandlers.length;
|
||||
});
|
||||
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
|
||||
clearCount += 1;
|
||||
});
|
||||
vi.spyOn(window, 'clearInterval').mockImplementation(() => 0);
|
||||
|
||||
mockDashboardApi({
|
||||
libraryScanStatuses: [],
|
||||
libraryScanStatusFailAfterTrigger: true,
|
||||
// initial load, immediate post-POST fetch, then every poll: the scan never shows up
|
||||
libraryScanStatusSequence: [[], [], [], [], []],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
]
|
||||
@@ -1328,25 +1343,57 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
// The immediate post-trigger status fetch errors (first grace-tick burn), but the
|
||||
// button must stay disabled and the poll must stay armed - a transient failure must
|
||||
// not be indistinguishable from "give up immediately".
|
||||
// Optimistically disabled; the immediate post-POST fetch ([]) burned one grace tick but keeps it pending.
|
||||
await waitFor(() => {
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
expect(intervalHandlers.length).toBeGreaterThan(0);
|
||||
|
||||
// scan-status keeps erroring on every poll tick...
|
||||
// Grace is PENDING_GRACE_TICKS (3): the immediate fetch used one, two more unseen polls expire it.
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(3);
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
|
||||
// ...but the same grace budget burns down on failures too, so persistent failure
|
||||
// eventually frees the button and stops the interval instead of polling forever.
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
|
||||
expect(clearCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps the scan button disabled on a 409 even when scan-status has not yet caught up (#232)', async () => {
|
||||
// 409 means the library is already scanning (a lock is held). The SPA must NOT re-enable the
|
||||
// button just because scan-status has not yet listed the in-progress scan (the queue->active
|
||||
// lag) - otherwise the user can fire repeated 409s. The pending flag is retained (button stays
|
||||
// disabled, no error toast) and is reconciled/expired by the normal polling path.
|
||||
mockDashboardApi({
|
||||
libraryScanStatusSequence: [
|
||||
[], // initial screen load
|
||||
[] // post-409 reconcile fetch: the in-progress scan is not visible in scan-status yet
|
||||
],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
],
|
||||
mutationFailures: {
|
||||
'/api/libraries/31/scan': { status: 409 }
|
||||
}
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
// Reconcile fetch happened and returned nothing active, yet the button stays disabled...
|
||||
await waitFor(() => {
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
|
||||
// ...and no error toast is shown for the benign conflict.
|
||||
expect(screen.queryByText(/Request failed/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Unable to scan/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Libraries API errors and retries', async () => {
|
||||
@@ -3910,7 +3957,8 @@ function mockDashboardApi({
|
||||
scanStatusShouldFail = true;
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
// Backend contract (#232): a successful queue is 202 Accepted, not a lying 200.
|
||||
return Promise.resolve(new Response(null, { status: 202 }));
|
||||
}
|
||||
|
||||
if (path === '/api/playouts') {
|
||||
|
||||
+76
-34
@@ -28,11 +28,6 @@ type LibrariesScreenState =
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
// A trigger'd scan disappears from the button-disabled set once either: it has been
|
||||
// observed at least once in scan-status (promoted to "active"), or this many poll
|
||||
// ticks pass without ever appearing (the scanner never picked it up / it failed silently).
|
||||
const PENDING_GRACE_TICKS = 3;
|
||||
|
||||
export function getMediaSources(): Promise<MediaSource[]> {
|
||||
return request<MediaSource[]>('/api/media-sources');
|
||||
}
|
||||
@@ -66,11 +61,22 @@ export function scanShow(libraryId: number, params: ScanShowParams): Promise<voi
|
||||
});
|
||||
}
|
||||
|
||||
// Pure: computes the surviving pending-id set for one poll tick (success or failure) and
|
||||
// mutates the grace-ticks map in place (delete on promote/expire, set on decrement) -
|
||||
// callers must still write pendingIdsRef.current with the returned set themselves, and
|
||||
// must call this exactly once per tick before that write to keep it a single, ref-free
|
||||
// computation that's safe to run under StrictMode double-invocation.
|
||||
// How many scan-status polls a pending library may go WITHOUT ever being observed active before we
|
||||
// give up on it. This is NOT the old lying-200 compensation (the POST now honestly returns
|
||||
// 409/404/422 when nothing was queued): a 202 truly queues a scan, but there is still inherent lag
|
||||
// between "queued" and the scan appearing in GetActiveScans, and a short/empty force-scan can even
|
||||
// finish between two 10s polls. Without this bound such a scan would never be observed active and
|
||||
// its optimistic "pending" flag would wedge the button disabled until reload. Bounds that to
|
||||
// PENDING_GRACE_TICKS * pollMs (~30s).
|
||||
const PENDING_GRACE_TICKS = 3;
|
||||
|
||||
// Reconcile optimistic "pending" scans against the authoritative active-scan set. A pending id seen
|
||||
// active at least once is dropped from pending (normal active/inactive pruning takes over from
|
||||
// there). A pending id not yet seen active is kept, but only for PENDING_GRACE_TICKS polls: the
|
||||
// per-id counter is decremented each tick and the id is dropped once it hits zero. Mutates the
|
||||
// grace-ticks map in place (delete on promote/expire, set on decrement); callers must still write
|
||||
// pendingIdsRef.current with the returned set themselves, and must call this exactly once per tick
|
||||
// before that write to keep it a single, ref-free computation safe under StrictMode double-invocation.
|
||||
function pruneGraceExpiredPending(
|
||||
pendingIds: Set<number>,
|
||||
graceTicks: Map<number, number>,
|
||||
@@ -112,6 +118,7 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
|
||||
// guard against double-submits without waiting for a render.
|
||||
const pendingIdsRef = useRef<Set<number>>(new Set());
|
||||
const activeIdsRef = useRef<Set<number>>(new Set());
|
||||
// Per-pending-id countdown of remaining polls before we give up waiting for it to appear active.
|
||||
const pendingGraceTicksRef = useRef<Map<number, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -160,12 +167,13 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
|
||||
const hadScanInProgress = hadScanInProgressRef.current;
|
||||
hadScanInProgressRef.current = scanStatuses.length > 0;
|
||||
|
||||
// Compute the pruned/promoted pending set - and write the grace-tick and pending
|
||||
// refs - OUTSIDE the setState updater. Updaters must be pure: StrictMode double-
|
||||
// invokes them (which would double-decrement grace ticks) and concurrent rendering
|
||||
// may invoke-and-discard one. Mirrors how activeIdsRef is written above.
|
||||
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, (libraryId) =>
|
||||
activeScanIds.has(libraryId)
|
||||
// Compute the promoted pending set - and write the pending ref - OUTSIDE the setState
|
||||
// updater. Updaters must be pure (StrictMode double-invokes them and concurrent rendering
|
||||
// may invoke-and-discard one). Mirrors how activeIdsRef is written above.
|
||||
const nextPending = pruneGraceExpiredPending(
|
||||
pendingIdsRef.current,
|
||||
pendingGraceTicksRef.current,
|
||||
(libraryId) => activeScanIds.has(libraryId)
|
||||
);
|
||||
|
||||
pendingIdsRef.current = nextPending;
|
||||
@@ -192,14 +200,10 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
|
||||
return;
|
||||
}
|
||||
|
||||
// A status-fetch failure never promotes a pending id to active, so it never
|
||||
// survives via "seen active" - it just burns down the same grace budget as a
|
||||
// successful poll that never saw it. This keeps a persistently-erroring endpoint
|
||||
// from leaving the scan button disabled and the poll interval running forever.
|
||||
// Active-scan state (activeIdsRef / scanStatuses) is left untouched: a transient
|
||||
// failure must not kill an in-progress scan's UI.
|
||||
// A persistently failing scan-status poll must not leave optimistically-pending buttons
|
||||
// stuck disabled forever. Burn a grace tick for every pending id (none can be confirmed
|
||||
// active while the fetch is failing) so pending still expires within the bounded window.
|
||||
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, () => false);
|
||||
|
||||
pendingIdsRef.current = nextPending;
|
||||
|
||||
setState((current) => {
|
||||
@@ -226,8 +230,8 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
|
||||
|
||||
hadScanInProgressRef.current = scanStatuses.length > 0;
|
||||
pendingIdsRef.current = new Set();
|
||||
activeIdsRef.current = new Set(scanStatuses.map((scan) => scan.libraryId));
|
||||
pendingGraceTicksRef.current = new Map();
|
||||
activeIdsRef.current = new Set(scanStatuses.map((scan) => scan.libraryId));
|
||||
setState({
|
||||
data: { scanStatuses, sources },
|
||||
error: null,
|
||||
@@ -266,12 +270,40 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const clearPending = useCallback((libraryId: number) => {
|
||||
const pendingIds = new Set(pendingIdsRef.current);
|
||||
pendingIds.delete(libraryId);
|
||||
pendingIdsRef.current = pendingIds;
|
||||
pendingGraceTicksRef.current.delete(libraryId);
|
||||
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
const pendingLibraryIds = new Set(current.pendingLibraryIds);
|
||||
pendingLibraryIds.delete(libraryId);
|
||||
|
||||
return {
|
||||
data: current.data,
|
||||
error: current.error,
|
||||
pendingLibraryIds,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const triggerScan = useCallback((libraryId: number): Promise<void> => {
|
||||
if (pendingIdsRef.current.has(libraryId) || activeIdsRef.current.has(libraryId)) {
|
||||
// Already pending or active - ignore the duplicate submission.
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Optimistically mark pending; the POST response tells us how to reconcile.
|
||||
pendingIdsRef.current = new Set(pendingIdsRef.current).add(libraryId);
|
||||
pendingGraceTicksRef.current.set(libraryId, PENDING_GRACE_TICKS);
|
||||
|
||||
@@ -289,12 +321,25 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
|
||||
});
|
||||
|
||||
return scanLibrary(libraryId)
|
||||
.then(() => loadScanStatuses())
|
||||
.then(() => {
|
||||
// 202 Accepted - a scan is genuinely queued. Poll scan-status; the pending flag is
|
||||
// promoted to "active" once the scan appears there.
|
||||
void loadScanStatuses();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const pendingIds = new Set(pendingIdsRef.current);
|
||||
pendingIds.delete(libraryId);
|
||||
pendingIdsRef.current = pendingIds;
|
||||
pendingGraceTicksRef.current.delete(libraryId);
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
// 409 Conflict - the library is already scanning (a lock is held). Benign: no toast. Keep
|
||||
// the optimistic pending flag (do NOT clear it) so the button stays disabled - clearing it
|
||||
// would re-enable the button whenever scan-status has not yet caught up to the in-progress
|
||||
// scan (the queue->active lag), letting the user fire repeated 409s. The pending flag is
|
||||
// promoted to "active" once the scan appears, or expires via the grace window if it never does.
|
||||
void loadScanStatuses();
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 (no such library) / 422 (sync disabled) / network error - nothing was queued, so
|
||||
// don't leave the button disabled; surface the error.
|
||||
clearPending(libraryId);
|
||||
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
@@ -305,18 +350,15 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
|
||||
return current;
|
||||
}
|
||||
|
||||
const pendingLibraryIds = new Set(current.pendingLibraryIds);
|
||||
pendingLibraryIds.delete(libraryId);
|
||||
|
||||
return {
|
||||
data: current.data,
|
||||
error: messageFromLibrariesError(error, 'Unable to scan library'),
|
||||
pendingLibraryIds,
|
||||
pendingLibraryIds: current.pendingLibraryIds,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [loadScanStatuses]);
|
||||
}, [clearPending, loadScanStatuses]);
|
||||
|
||||
if (state.status === 'success') {
|
||||
const activeLibraryIds = new Set(state.data.scanStatuses.map((scan) => scan.libraryId));
|
||||
|
||||
Reference in New Issue
Block a user