The six plain-bool lock flags (Plex, Trakt, Emby/Jellyfin/Plex collections, troubleshooting playback) used a non-atomic check-then-set, so two concurrent Lock* callers could both win. Convert them to int flags 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. The three ConcurrentDictionary-backed kinds (Library/Playout/RemoteMediaSource) were already atomic; drop their redundant ContainsKey pre-checks. Define the ownership contract (tokenless single-owner discipline, no interface change) on IEntityLocker and in docs/decisions.md: a true from Lock* confers ownership of exactly one release; Unlock* on an unlocked slot returns false, fires no event, and logs a warning (the double-release / non-owner tripwire). Adds EntityLockerTests (real locker, parallel-caller races) proving exactly one winner per kind, one-releaser-per-slot, and event-fires-once-per-transition. Ref #231. Scan-lifecycle call-site fixes that consume this contract land in the same PR (#232); the BuildPlayout/subtitle finally-gating is #234. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
275 lines
8.3 KiB
C#
275 lines
8.3 KiB
C#
using System.Collections.Concurrent;
|
|
using ErsatzTV.Core.Interfaces.Locking;
|
|
using ErsatzTV.Core.Notifications;
|
|
using MediatR;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ErsatzTV.Infrastructure.Locking;
|
|
|
|
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();
|
|
|
|
// 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;
|
|
public event EventHandler<Type> OnRemoteMediaSourceChanged;
|
|
public event EventHandler OnTraktChanged;
|
|
public event EventHandler OnEmbyCollectionsChanged;
|
|
public event EventHandler OnJellyfinCollectionsChanged;
|
|
public event EventHandler OnPlexCollectionsChanged;
|
|
public event EventHandler OnTroubleshootingPlaybackChanged;
|
|
|
|
public bool LockLibrary(int libraryId)
|
|
{
|
|
if (_lockedLibraries.TryAdd(libraryId, 0))
|
|
{
|
|
OnLibraryChanged?.Invoke(this, EventArgs.Empty);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public bool UnlockLibrary(int libraryId)
|
|
{
|
|
if (_lockedLibraries.TryRemove(libraryId, out byte _))
|
|
{
|
|
OnLibraryChanged?.Invoke(this, EventArgs.Empty);
|
|
return true;
|
|
}
|
|
|
|
logger.LogWarning(
|
|
"UnlockLibrary called for library {LibraryId} that was not locked (double-release or non-owner release)",
|
|
libraryId);
|
|
return false;
|
|
}
|
|
|
|
public bool IsLibraryLocked(int libraryId) =>
|
|
_lockedLibraries.ContainsKey(libraryId);
|
|
|
|
public bool LockPlex()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _plex, 1, 0) == 0)
|
|
{
|
|
OnPlexChanged?.Invoke(this, EventArgs.Empty);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public bool UnlockPlex()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _plex, 0, 1) == 1)
|
|
{
|
|
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() => Volatile.Read(ref _plex) == 1;
|
|
|
|
public bool IsRemoteMediaSourceLocked<TMediaSource>() =>
|
|
_lockedRemoteMediaSourceTypes.ContainsKey(typeof(TMediaSource));
|
|
|
|
public bool LockRemoteMediaSource<TMediaSource>()
|
|
{
|
|
Type mediaSourceType = typeof(TMediaSource);
|
|
|
|
if (_lockedRemoteMediaSourceTypes.TryAdd(mediaSourceType, 0))
|
|
{
|
|
OnRemoteMediaSourceChanged?.Invoke(this, mediaSourceType);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public bool UnlockRemoteMediaSource<TMediaSource>()
|
|
{
|
|
Type mediaSourceType = typeof(TMediaSource);
|
|
|
|
if (_lockedRemoteMediaSourceTypes.TryRemove(mediaSourceType, out byte _))
|
|
{
|
|
OnRemoteMediaSourceChanged?.Invoke(this, mediaSourceType);
|
|
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 (Interlocked.CompareExchange(ref _trakt, 1, 0) == 0)
|
|
{
|
|
OnTraktChanged?.Invoke(this, EventArgs.Empty);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public bool UnlockTrakt()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _trakt, 0, 1) == 1)
|
|
{
|
|
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() => Volatile.Read(ref _trakt) == 1;
|
|
|
|
public bool LockEmbyCollections()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _embyCollections, 1, 0) == 0)
|
|
{
|
|
OnEmbyCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public bool UnlockEmbyCollections()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _embyCollections, 0, 1) == 1)
|
|
{
|
|
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() => Volatile.Read(ref _embyCollections) == 1;
|
|
|
|
public bool LockJellyfinCollections()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _jellyfinCollections, 1, 0) == 0)
|
|
{
|
|
OnJellyfinCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public bool UnlockJellyfinCollections()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _jellyfinCollections, 0, 1) == 1)
|
|
{
|
|
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() => Volatile.Read(ref _jellyfinCollections) == 1;
|
|
|
|
public bool LockPlexCollections()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _plexCollections, 1, 0) == 0)
|
|
{
|
|
OnPlexCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public bool UnlockPlexCollections()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _plexCollections, 0, 1) == 1)
|
|
{
|
|
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() => Volatile.Read(ref _plexCollections) == 1;
|
|
|
|
public async Task<bool> LockPlayout(int playoutId)
|
|
{
|
|
if (_lockedPlayouts.TryAdd(playoutId, 0))
|
|
{
|
|
await mediator.Publish(new PlayoutUpdatedNotification(playoutId, true));
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public async Task<bool> UnlockPlayout(int playoutId)
|
|
{
|
|
if (_lockedPlayouts.TryRemove(playoutId, out byte _))
|
|
{
|
|
await mediator.Publish(new PlayoutUpdatedNotification(playoutId, false));
|
|
return true;
|
|
}
|
|
|
|
logger.LogWarning(
|
|
"UnlockPlayout called for playout {PlayoutId} that was not locked (double-release or non-owner release)",
|
|
playoutId);
|
|
return false;
|
|
}
|
|
|
|
public bool IsPlayoutLocked(int playoutId) => _lockedPlayouts.ContainsKey(playoutId);
|
|
|
|
public bool LockTroubleshootingPlayback()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _troubleshootingPlayback, 1, 0) == 0)
|
|
{
|
|
OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public bool UnlockTroubleshootingPlayback()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _troubleshootingPlayback, 0, 1) == 1)
|
|
{
|
|
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() => Volatile.Read(ref _troubleshootingPlayback) == 1;
|
|
}
|