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 logger) : IEntityLocker { private readonly ConcurrentDictionary _lockedLibraries = new(); private readonly ConcurrentDictionary _lockedPlayouts = new(); private readonly ConcurrentDictionary _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 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() => _lockedRemoteMediaSourceTypes.ContainsKey(typeof(TMediaSource)); public bool LockRemoteMediaSource() { Type mediaSourceType = typeof(TMediaSource); if (_lockedRemoteMediaSourceTypes.TryAdd(mediaSourceType, 0)) { OnRemoteMediaSourceChanged?.Invoke(this, mediaSourceType); return true; } return false; } public bool UnlockRemoteMediaSource() { 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 LockPlayout(int playoutId) { if (_lockedPlayouts.TryAdd(playoutId, 0)) { await mediator.Publish(new PlayoutUpdatedNotification(playoutId, true)); return true; } return false; } public async Task 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; }