From 4852c362687d6a5498bec3957191cd6bb69527bf Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 11 Jul 2026 15:42:40 +0200 Subject: [PATCH] feat(api): Plex media-source write API + lock-lifecycle fixes (#202 slice S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New PlexMediaSourcesController (/api/media-sources/plex) P1-P8 wrapping existing MediatR commands: state GET, pin-flow, sign-out, per-server libraries/path-replacements GET+PUT, and refresh — VMs projected to the S0 shared DTOs, ApiResults mapping, 404 controller pre-checks, #215-style 409 lock guards, [EndpointGroupName("general")]. Lock-lifecycle hardening (the tricky part): - TryCompletePlexPinFlowHandler now releases the Plex lock ONLY on its non-handoff exits (timeout-throw, poll exception, enqueue exception, the dead return-false) via try/catch — NOT an unconditional finally. On success the lock is handed off to SynchronizePlexMediaSources (the sole releaser after discovery); a finally would double-release and release before discovery, re-opening the finding-5 poll race. Fixes the latent leak where an abandoned pin flow wedged Plex locked until restart. - StartPlexPinFlow controller compensates UnlockPlex on the Left branch AND any thrown dispatch/enqueue; only the Right/200 path holds the lock. - SignOutOfPlexHandler wraps its work in try/finally { UnlockPlex() } — a terminal handler with no handoff, so unconditional release is correct. - Post-save library sync enqueues SynchronizePlexLibraryByIdIfNeeded (Unlock:false) then SynchronizePlexNetworks (Unlock:true) — one lock, one release on the last message, compensating-unlock if the 2nd enqueue throws (corrects the Blazor Unlock-ordering bug, finding 6). Data-integrity hardening: - UpdatePlexPathReplacementsHandler rejects (422, no mutation) any positive Id not owned by the route source, blank RemotePath/LocalPath, and null list/items (findings 2c/8). - MediaSourceRepository Plex path-replacement UPDATE gains AND PlexMediaSourceId = @id (Jellyfin/Emby untouched — slice S3). - ReplaceLibraryPreferences controller validates the id set against the source's libraries (rejects unowned + Id=0), returns the reloaded list (ids change on disable). Tests (NUnit/Shouldly/NSubstitute), 34 new, all green: pin-flow lock released on thrown-cancellation/poll-throw/enqueue-throw AND held on success (no double-release); sign-out finally-release under a throwing dependency; cross-source/nonblank/null path-replacement 422s; library-prefs id-not-owned 422; post-save enqueue exact messages + Unlock flags; full controller route/404/409/422 coverage. Refs #202 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Plex/Commands/SignOutOfPlexHandler.cs | 22 +- .../Commands/TryCompletePlexPinFlowHandler.cs | 46 +- .../UpdatePlexPathReplacementsHandler.cs | 56 ++- .../Repositories/MediaSourceRepository.cs | 7 +- .../Application/Plex/PlexHandlerTests.cs | 257 ++++++++++ .../PlexMediaSourcesControllerTests.cs | 449 ++++++++++++++++++ .../Api/PlexMediaSourcesController.cs | 312 ++++++++++++ 7 files changed, 1124 insertions(+), 25 deletions(-) create mode 100644 ErsatzTV.Tests/Application/Plex/PlexHandlerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/PlexMediaSourcesControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/PlexMediaSourcesController.cs diff --git a/ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs b/ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs index c20d4231c..4d1986bfb 100644 --- a/ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs @@ -27,12 +27,22 @@ public class SignOutOfPlexHandler : IRequestHandler> Handle(SignOutOfPlex request, CancellationToken cancellationToken) { - List ids = await _mediaSourceRepository.DeleteAllPlex(); - await _searchIndex.RemoveItems(ids); - _searchIndex.Commit(); - await _plexSecretStore.DeleteAll(); - _entityLocker.UnlockPlex(); + // Terminal handler (no lock handoff): release the Plex lock on EVERY exit via finally so a throw + // from any awaited dependency (repo delete, search-index commit, secret store) cannot wedge Plex + // locked at 409 until restart (#202 §C6 / finding 7). This is the UNCONDITIONAL-finally case — + // contrast the pin-flow handlers, which hand the lock off and must NOT use a blanket finally. + try + { + List ids = await _mediaSourceRepository.DeleteAllPlex(); + await _searchIndex.RemoveItems(ids); + _searchIndex.Commit(); + await _plexSecretStore.DeleteAll(); - return Unit.Default; + return Unit.Default; + } + finally + { + _entityLocker.UnlockPlex(); + } } } diff --git a/ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlowHandler.cs b/ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlowHandler.cs index bc4fbfd48..2a8e0c5a4 100644 --- a/ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlowHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlowHandler.cs @@ -1,5 +1,6 @@ -using System.Threading.Channels; +using System.Threading.Channels; using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Plex; namespace ErsatzTV.Application.Plex; @@ -7,34 +8,59 @@ namespace ErsatzTV.Application.Plex; public class TryCompletePlexPinFlowHandler : IRequestHandler> { private readonly ChannelWriter _channel; + private readonly IEntityLocker _entityLocker; private readonly IPlexTvApiClient _plexTvApiClient; public TryCompletePlexPinFlowHandler( IPlexTvApiClient plexTvApiClient, - ChannelWriter channel) + ChannelWriter channel, + IEntityLocker entityLocker) { _plexTvApiClient = plexTvApiClient; _channel = channel; + _entityLocker = entityLocker; } public async Task> Handle(TryCompletePlexPinFlow request, CancellationToken cancellationToken) { + // Lock-release discipline (#202 §C1.6 / §C6): the Plex lock this pin flow holds is released + // ONLY on non-handoff exits — the 2-minute timeout (Task.Delay throws + // OperationCanceledException), a poll exception, a failed enqueue, or the (effectively dead) + // return-false at loop entry. On SUCCESS the lock is HANDED OFF to SynchronizePlexMediaSources, + // whose handler is the sole releaser after server discovery (SynchronizePlexMediaSourcesHandler). + // This is deliberately NOT an unconditional finally: a blanket release here would double-release + // AND release before discovery, re-opening the finding-5 race (an empty server list reading as + // success). Contrast the terminal SignOutOfPlexHandler, which DOES use finally (it has no handoff). using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); CancellationToken token = linkedTokenSource.Token; - while (!token.IsCancellationRequested) + try { - bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin); - if (result) + while (!token.IsCancellationRequested) { - await _channel.WriteAsync(new SynchronizePlexMediaSources(), token); - return true; + bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin); + if (result) + { + // hand the lock off to the sync handler — do NOT release on this success path + await _channel.WriteAsync(new SynchronizePlexMediaSources(), token); + return true; + } + + await Task.Delay(TimeSpan.FromSeconds(1), token); } - await Task.Delay(TimeSpan.FromSeconds(1), token); + // effectively unreachable (Task.Delay throws on cancellation before the loop condition is + // re-evaluated) but if the flow ever ends here it abandoned without auth → release + _entityLocker.UnlockPlex(); + return false; + } + catch (Exception) + { + // non-handoff exit: timeout-throw, poll exception, or failed enqueue — release the lock so an + // abandoned flow does not wedge Plex locked, then rethrow (PlexService logs it as before) + _entityLocker.UnlockPlex(); + throw; } - - return false; } } diff --git a/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs b/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs index 6192f90dd..70a8b02c0 100644 --- a/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs @@ -35,14 +35,56 @@ public class private static PlexPathReplacement Project(PlexPathReplacementItem vm) => new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath }; - private Task> Validate( + private async Task> Validate( UpdatePlexPathReplacements request, - CancellationToken cancellationToken) => - PlexMediaSourceMustExist(request, cancellationToken); + CancellationToken cancellationToken) + { + Option maybeSource = + await _mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken); - private Task> PlexMediaSourceMustExist( + foreach (PlexMediaSource plexMediaSource in maybeSource) + { + return ValidatePathReplacements(request, plexMediaSource); + } + + return Fail( + BaseError.New($"Plex media source {request.PlexMediaSourceId} does not exist.")); + } + + // Programmatic clients now reach this handler directly (#202), so validate what the Blazor form + // enforced plus the cross-source ownership hole (finding 2c/8): reject a null list, null items, + // blank RemotePath/LocalPath, and any positive Id NOT owned by this source — all before any write, + // so there is no partial mutation. + private static Validation ValidatePathReplacements( UpdatePlexPathReplacements request, - CancellationToken cancellationToken) => - _mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken) - .Map(v => v.ToValidation($"Plex media source {request.PlexMediaSourceId} does not exist.")); + PlexMediaSource plexMediaSource) + { + List items = request.PathReplacements; + if (items is null) + { + return Fail(BaseError.New("[PathReplacements] is required")); + } + + if (items.Any(i => i is null)) + { + return Fail(BaseError.New("[PathReplacements] must not contain null items")); + } + + if (items.Any(i => string.IsNullOrWhiteSpace(i.PlexPath) || string.IsNullOrWhiteSpace(i.LocalPath))) + { + return Fail( + BaseError.New("Each path replacement requires a non-empty RemotePath and LocalPath")); + } + + var ownedIds = Optional(plexMediaSource.PathReplacements).Flatten().Map(pr => pr.Id).ToHashSet(); + List foreignIds = items.Filter(i => i.Id > 0 && !ownedIds.Contains(i.Id)).Map(i => i.Id).ToList(); + if (foreignIds.Count > 0) + { + return Fail( + BaseError.New( + $"Path replacement {foreignIds[0]} does not belong to Plex media source {request.PlexMediaSourceId}")); + } + + return Success(plexMediaSource); + } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs index 7541b221f..05df5b4fc 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -396,11 +396,14 @@ public class MediaSourceRepository(IDbContextFactory dbContextFactory foreach (PlexPathReplacement update in toUpdate) { + // Scope the UPDATE to the owning source (#202 finding 2c): without the PlexMediaSourceId + // predicate a PUT to source A could overwrite source B's row by id (defense-in-depth behind + // the handler's ownership guard). await dbContext.Connection.ExecuteAsync( @"UPDATE PlexPathReplacement SET PlexPath = @PlexPath, LocalPath = @LocalPath - WHERE Id = @Id", - new { update.PlexPath, update.LocalPath, update.Id }); + WHERE Id = @Id AND PlexMediaSourceId = @PlexMediaSourceId", + new { update.PlexPath, update.LocalPath, update.Id, PlexMediaSourceId = plexMediaSourceId }); } foreach (PlexPathReplacement delete in toDelete) diff --git a/ErsatzTV.Tests/Application/Plex/PlexHandlerTests.cs b/ErsatzTV.Tests/Application/Plex/PlexHandlerTests.cs new file mode 100644 index 000000000..3ed9cb877 --- /dev/null +++ b/ErsatzTV.Tests/Application/Plex/PlexHandlerTests.cs @@ -0,0 +1,257 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Plex; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Core.Plex; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Plex; + +[TestFixture] +public class PlexHandlerTests +{ + // ----- TryCompletePlexPinFlowHandler: release ONLY on non-handoff exits (§C1.6 / finding 5) ----- + + [Test] + public async Task PinFlow_Should_Release_Lock_On_Thrown_Cancellation() + { + IPlexTvApiClient plexTvApiClient = Substitute.For(); + var channel = Substitute.For>(); + IEntityLocker entityLocker = Substitute.For(); + + using var externalCts = new CancellationTokenSource(); + // poll returns false, then cancels the flow's token so Task.Delay THROWS (the real timeout path) + plexTvApiClient.TryCompletePinFlow(Arg.Any()) + .Returns(_ => + { + externalCts.Cancel(); + return false; + }); + + var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker); + + await Should.ThrowAsync(() => + handler.Handle(new TryCompletePlexPinFlow(Pin()), externalCts.Token)); + + entityLocker.Received(1).UnlockPlex(); + } + + [Test] + public async Task PinFlow_Should_Release_Lock_On_Poll_Exception() + { + IPlexTvApiClient plexTvApiClient = Substitute.For(); + var channel = Substitute.For>(); + IEntityLocker entityLocker = Substitute.For(); + + plexTvApiClient.TryCompletePinFlow(Arg.Any()) + .Returns(_ => throw new InvalidOperationException("poll boom")); + + var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker); + + await Should.ThrowAsync(() => + handler.Handle(new TryCompletePlexPinFlow(Pin()), CancellationToken.None)); + + entityLocker.Received(1).UnlockPlex(); + } + + [Test] + public async Task PinFlow_Should_Release_Lock_On_Enqueue_Exception() + { + IPlexTvApiClient plexTvApiClient = Substitute.For(); + var channel = Substitute.For>(); + IEntityLocker entityLocker = Substitute.For(); + + plexTvApiClient.TryCompletePinFlow(Arg.Any()).Returns(true); + channel.WriteAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("enqueue boom")); + + var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker); + + await Should.ThrowAsync(() => + handler.Handle(new TryCompletePlexPinFlow(Pin()), CancellationToken.None)); + + entityLocker.Received(1).UnlockPlex(); + } + + [Test] + public async Task PinFlow_Success_Should_Not_Release_Lock_In_Handler() + { + IPlexTvApiClient plexTvApiClient = Substitute.For(); + var channel = Substitute.For>(); + IEntityLocker entityLocker = Substitute.For(); + + plexTvApiClient.TryCompletePinFlow(Arg.Any()).Returns(true); + + var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker); + + Either result = + await handler.Handle(new TryCompletePlexPinFlow(Pin()), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + // the lock is handed off to SynchronizePlexMediaSources — the handler must NOT release it + entityLocker.DidNotReceive().UnlockPlex(); + await channel.Received(1).WriteAsync( + Arg.Any(), + Arg.Any()); + } + + // ----- SignOutOfPlexHandler: unconditional finally-release (§C6 / finding 7) ----- + + [Test] + public async Task SignOut_Should_Release_Lock_When_Dependency_Throws() + { + IMediaSourceRepository mediaSourceRepository = Substitute.For(); + IPlexSecretStore plexSecretStore = Substitute.For(); + IEntityLocker entityLocker = Substitute.For(); + ISearchIndex searchIndex = Substitute.For(); + + mediaSourceRepository.DeleteAllPlex() + .Returns>(_ => throw new InvalidOperationException("delete boom")); + + var handler = new SignOutOfPlexHandler(mediaSourceRepository, plexSecretStore, entityLocker, searchIndex); + + await Should.ThrowAsync(() => + handler.Handle(new SignOutOfPlex(), CancellationToken.None)); + + entityLocker.Received(1).UnlockPlex(); + } + + [Test] + public async Task SignOut_Should_Release_Lock_On_Success() + { + IMediaSourceRepository mediaSourceRepository = Substitute.For(); + IPlexSecretStore plexSecretStore = Substitute.For(); + IEntityLocker entityLocker = Substitute.For(); + ISearchIndex searchIndex = Substitute.For(); + + mediaSourceRepository.DeleteAllPlex().Returns(new List { 1, 2 }); + + var handler = new SignOutOfPlexHandler(mediaSourceRepository, plexSecretStore, entityLocker, searchIndex); + + Either result = await handler.Handle(new SignOutOfPlex(), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + entityLocker.Received(1).UnlockPlex(); + } + + // ----- UpdatePlexPathReplacementsHandler: ownership + nonblank + null validation (§C4b / findings 2c/8) ----- + + [Test] + public async Task PathReplacements_Should_Reject_CrossSource_Id_Without_Mutating() + { + IMediaSourceRepository mediaSourceRepository = Substitute.For(); + mediaSourceRepository.GetPlex(1, Arg.Any()) + .Returns(Option.Some(SourceWithReplacement(1, replacementId: 5))); + + var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository); + + // id 99 is not owned by source 1 (its only replacement is id 5) → must not touch the repo + Either result = await handler.Handle( + new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(99, "/remote", "/local")]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task PathReplacements_Should_Reject_Blank_Rows_Without_Mutating() + { + IMediaSourceRepository mediaSourceRepository = Substitute.For(); + mediaSourceRepository.GetPlex(1, Arg.Any()) + .Returns(Option.Some(SourceWithReplacement(1, replacementId: 5))); + + var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository); + + Either result = await handler.Handle( + new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(0, " ", "/local")]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task PathReplacements_Should_Reject_Null_Items_Without_Mutating() + { + IMediaSourceRepository mediaSourceRepository = Substitute.For(); + mediaSourceRepository.GetPlex(1, Arg.Any()) + .Returns(Option.Some(SourceWithReplacement(1, replacementId: 5))); + + var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository); + + Either result = await handler.Handle( + new UpdatePlexPathReplacements(1, [null]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task PathReplacements_Should_Accept_Owned_Rows_And_Mutate() + { + IMediaSourceRepository mediaSourceRepository = Substitute.For(); + mediaSourceRepository.GetPlex(1, Arg.Any()) + .Returns(Option.Some(SourceWithReplacement(1, replacementId: 5))); + mediaSourceRepository.UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()) + .Returns(Unit.Default); + + var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository); + + Either result = await handler.Handle( + new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(5, "/remote", "/local")]), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + await mediaSourceRepository.Received(1).UpdatePathReplacements( + 1, + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + private static PlexAuthPin Pin() => new(1, "code", "client-id"); + + private static PlexMediaSource SourceWithReplacement(int sourceId, int replacementId) => + new() + { + Id = sourceId, + PathReplacements = + [ + new PlexPathReplacement + { + Id = replacementId, + PlexPath = "/existing-remote", + LocalPath = "/existing-local", + PlexMediaSourceId = sourceId + } + ] + }; +} diff --git a/ErsatzTV.Tests/Controllers/PlexMediaSourcesControllerTests.cs b/ErsatzTV.Tests/Controllers/PlexMediaSourcesControllerTests.cs new file mode 100644 index 000000000..6b7431d15 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/PlexMediaSourcesControllerTests.cs @@ -0,0 +1,449 @@ +using System.Reflection; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Plex; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Plex; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class PlexMediaSourcesControllerTests +{ + private ChannelWriter _channel = null!; + private PlexMediaSourcesController _controller = null!; + private IEntityLocker _entityLocker = null!; + private IMediator _mediator = null!; + private IPlexSecretStore _plexSecretStore = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _entityLocker = Substitute.For(); + _plexSecretStore = Substitute.For(); + _channel = Substitute.For>(); + _controller = new PlexMediaSourcesController(_mediator, _entityLocker, _plexSecretStore, _channel); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(PlexMediaSourcesController.GetState), "GET", "/api/media-sources/plex"); + ShouldHaveActionRoute( + nameof(PlexMediaSourcesController.StartPinFlow), + "POST", + "/api/media-sources/plex/pin-flow"); + ShouldHaveActionRoute(nameof(PlexMediaSourcesController.SignOutOfPlex), "POST", "/api/media-sources/plex/sign-out"); + ShouldHaveActionRoute( + nameof(PlexMediaSourcesController.GetLibraries), + "GET", + "/api/media-sources/plex/{id:int}/libraries"); + ShouldHaveActionRoute( + nameof(PlexMediaSourcesController.ReplaceLibraryPreferences), + "PUT", + "/api/media-sources/plex/{id:int}/libraries"); + ShouldHaveActionRoute( + nameof(PlexMediaSourcesController.GetPathReplacements), + "GET", + "/api/media-sources/plex/{id:int}/path-replacements"); + ShouldHaveActionRoute( + nameof(PlexMediaSourcesController.ReplacePathReplacements), + "PUT", + "/api/media-sources/plex/{id:int}/path-replacements"); + ShouldHaveActionRoute( + nameof(PlexMediaSourcesController.RefreshLibraries), + "POST", + "/api/media-sources/plex/{id:int}/refresh-libraries"); + } + + // ----- P1 GetState ----- + + [Test] + public async Task GetState_Should_Stamp_Authorized_Locked_And_Servers() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(3, "Server", "http://plex:32400") }); + _plexSecretStore.GetUserAuthTokens() + .Returns(new List { new("me@example.com", "token") }); + _entityLocker.IsPlexLocked().Returns(true); + + RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); + + result.IsAuthorized.ShouldBeTrue(); + result.IsLocked.ShouldBeTrue(); + result.Servers.Single().Id.ShouldBe(3); + result.Servers.Single().Address.ShouldBe("http://plex:32400"); + } + + [Test] + public async Task GetState_Should_Report_Unauthorized_With_No_Tokens() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List()); + _plexSecretStore.GetUserAuthTokens().Returns(new List()); + + RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); + + result.IsAuthorized.ShouldBeFalse(); + } + + // ----- P2 StartPinFlow ----- + + [Test] + public async Task StartPinFlow_Should_Return_409_When_Lock_Held() + { + _entityLocker.LockPlex().Returns(false); + + IActionResult result = await _controller.StartPinFlow(CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task StartPinFlow_Should_Return_200_And_Hold_Lock_On_Success() + { + _entityLocker.LockPlex().Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right("https://app.plex.tv/auth#?code=abc")); + + IActionResult result = await _controller.StartPinFlow(CancellationToken.None); + + var model = result.ShouldBeOfType().Value.ShouldBeOfType(); + model.AuthUrl.ShouldBe("https://app.plex.tv/auth#?code=abc"); + // the Right/200 path keeps the lock held for the background flow + _entityLocker.DidNotReceive().UnlockPlex(); + } + + [Test] + public async Task StartPinFlow_Should_Compensate_Unlock_On_Left() + { + _entityLocker.LockPlex().Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("plex.tv error"))); + + IActionResult result = await _controller.StartPinFlow(CancellationToken.None); + + result.ShouldBeOfType(); + _entityLocker.Received(1).UnlockPlex(); + } + + [Test] + public async Task StartPinFlow_Should_Compensate_Unlock_On_Thrown_Dispatch() + { + _entityLocker.LockPlex().Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns>(_ => throw new InvalidOperationException("channel closed")); + + await Should.ThrowAsync(() => _controller.StartPinFlow(CancellationToken.None)); + + _entityLocker.Received(1).UnlockPlex(); + } + + // ----- P3 SignOut ----- + + [Test] + public async Task SignOut_Should_Return_409_When_Lock_Held() + { + _entityLocker.LockPlex().Returns(false); + + IActionResult result = await _controller.SignOutOfPlex(CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SignOut_Should_Return_204_And_Not_Release_In_Controller() + { + _entityLocker.LockPlex().Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.SignOutOfPlex(CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send(Arg.Any(), Arg.Any()); + // the handler is the designated releaser (finally) — the controller must not double-release + _entityLocker.DidNotReceive().UnlockPlex(); + } + + // ----- P4 GetLibraries ----- + + [Test] + public async Task GetLibraries_Should_Return_404_When_Source_Missing() + { + SourceExists(false); + + IActionResult result = await _controller.GetLibraries(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetLibraries_Should_Return_200_List() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(5, "Movies", LibraryMediaKind.Movies, true) }); + + IActionResult result = await _controller.GetLibraries(3, CancellationToken.None); + + var list = result.ShouldBeOfType().Value.ShouldBeOfType>(); + list.Single().Id.ShouldBe(5); + list.Single().ShouldSyncItems.ShouldBeTrue(); + } + + // ----- P5 ReplaceLibraryPreferences ----- + + [Test] + public async Task ReplaceLibraries_Should_Return_404_When_Source_Missing() + { + SourceExists(false); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 9, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceLibraries_Should_Return_422_When_Id_Not_Owned() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(5, "Movies", LibraryMediaKind.Movies, true) }); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 3, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(999, true)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceLibraries_Should_Return_422_For_Zero_Id_Row() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(5, "Movies", LibraryMediaKind.Movies, true) }); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 3, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(0, true)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceLibraries_Should_Save_Enqueue_Ordered_Pair_And_Return_Reloaded() + { + SourceExists(true); + // owned set for validation, then the reloaded set (drives the sync loop + the response) + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(5, "Shows", LibraryMediaKind.Shows, true) }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _entityLocker.LockLibrary(5).Returns(true); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 3, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]), + CancellationToken.None); + + var list = result.ShouldBeOfType().Value.ShouldBeOfType>(); + list.Single().Id.ShouldBe(5); + + // library message carries Unlock:false; the networks message carries the single release Unlock:true + Received.InOrder(() => + { + _channel.WriteAsync( + Arg.Is(s => s.PlexLibraryId == 5 && !s.Unlock), + Arg.Any()); + _channel.WriteAsync( + Arg.Is(n => n.PlexLibraryId == 5 && n.Unlock), + Arg.Any()); + }); + } + + [Test] + public async Task ReplaceLibraries_Should_Skip_Enqueue_For_Locked_Library() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(5, "Shows", LibraryMediaKind.Shows, true) }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _entityLocker.LockLibrary(5).Returns(false); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 3, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _channel.DidNotReceive().WriteAsync( + Arg.Any(), + Arg.Any()); + } + + // ----- P6 GetPathReplacements ----- + + [Test] + public async Task GetPathReplacements_Should_Return_404_When_Source_Missing() + { + SourceExists(false); + + IActionResult result = await _controller.GetPathReplacements(9, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetPathReplacements_Should_Map_Plex_Path_To_Remote_Path() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(7, "/plex", "/local") }); + + IActionResult result = await _controller.GetPathReplacements(3, CancellationToken.None); + + var list = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + list.Single().RemotePath.ShouldBe("/plex"); + list.Single().LocalPath.ShouldBe("/local"); + } + + // ----- P7 ReplacePathReplacements ----- + + [Test] + public async Task ReplacePathReplacements_Should_Return_404_When_Source_Missing() + { + SourceExists(false); + + IActionResult result = await _controller.ReplacePathReplacements( + 9, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(0, "/remote", "/local")]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplacePathReplacements_Should_Return_422_On_Handler_Left() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("cross-source"))); + + IActionResult result = await _controller.ReplacePathReplacements( + 3, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(99, "/remote", "/local")]), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task ReplacePathReplacements_Should_Return_200_Reloaded_On_Success() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(7, "/plex", "/local") }); + + IActionResult result = await _controller.ReplacePathReplacements( + 3, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(7, "/plex", "/local")]), + CancellationToken.None); + + var list = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + list.Single().Id.ShouldBe(7); + } + + // ----- P8 RefreshLibraries ----- + + [Test] + public async Task RefreshLibraries_Should_Return_404_When_Source_Missing() + { + SourceExists(false); + + IActionResult result = await _controller.RefreshLibraries(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _channel.DidNotReceive().WriteAsync( + Arg.Any(), + Arg.Any()); + } + + [Test] + public async Task RefreshLibraries_Should_Return_409_When_Plex_Locked() + { + SourceExists(true); + _entityLocker.IsPlexLocked().Returns(true); + + IActionResult result = await _controller.RefreshLibraries(3, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); + await _channel.DidNotReceive().WriteAsync( + Arg.Any(), + Arg.Any()); + } + + [Test] + public async Task RefreshLibraries_Should_Return_202_And_Enqueue() + { + SourceExists(true); + _entityLocker.IsPlexLocked().Returns(false); + + IActionResult result = await _controller.RefreshLibraries(3, CancellationToken.None); + + result.ShouldBeOfType(); + await _channel.Received(1).WriteAsync( + Arg.Is(s => s.PlexMediaSourceId == 3), + Arg.Any()); + } + + private void SourceExists(bool exists) => + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(exists + ? Option.Some(new PlexMediaSourceViewModel(3, "Server", "http://plex:32400")) + : Option.None); + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(PlexMediaSourcesController).GetMethod(actionName) + ?? throw new AssertionException($"Missing action {actionName}"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV/Controllers/Api/PlexMediaSourcesController.cs b/ErsatzTV/Controllers/Api/PlexMediaSourcesController.cs new file mode 100644 index 000000000..c47d61e6f --- /dev/null +++ b/ErsatzTV/Controllers/Api/PlexMediaSourcesController.cs @@ -0,0 +1,312 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Plex; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class PlexMediaSourcesController( + IMediator mediator, + IEntityLocker entityLocker, + IPlexSecretStore plexSecretStore, + ChannelWriter scannerWorkerChannel) : ControllerBase +{ + private const string PlexBusyTitle = "Plex sign-in or sync in progress"; + + private const string PlexBusyDetail = + "A Plex sign-in or synchronization is currently in progress; try again once it completes."; + + private static IActionResult PlexLockedProblem() => + ApiResults.ConflictProblem(PlexBusyTitle, PlexBusyDetail); + + [HttpGet("/api/media-sources/plex", Name = "GetPlexState")] + [Tags("Plex")] + [EndpointSummary("Get Plex connection state")] + [EndpointDescription( + "Returns whether ErsatzTV is authorized with plex.tv, whether a Plex sign-in/sync lock is held, " + + "and the list of discovered Plex servers. Poll this during the pin flow: authorized && !locked = done.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteMediaSourceStateResponseModel), StatusCodes.Status200OK)] + public async Task GetState(CancellationToken cancellationToken) + { + List servers = + await mediator.Send(new GetAllPlexMediaSources(), cancellationToken); + bool isAuthorized = (await plexSecretStore.GetUserAuthTokens()).Count > 0; + return new RemoteMediaSourceStateResponseModel( + isAuthorized, + entityLocker.IsPlexLocked(), + servers.Map(ToItemResponse).ToList()); + } + + [HttpPost("/api/media-sources/plex/pin-flow", Name = "StartPlexPinFlow")] + [Tags("Plex")] + [EndpointSummary("Start the Plex sign-in pin flow")] + [EndpointDescription( + "Acquires the Plex lock and starts the OAuth pin flow, returning the plex.tv authorization URL to " + + "open in a new tab. The lock stays held for the background flow; poll GET /api/media-sources/plex " + + "until authorized && !locked. Also used to fix credentials for an existing but unauthorized server.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PlexPinFlowResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task StartPinFlow(CancellationToken cancellationToken) + { + // The lock IS the running sign-in flow (§3b): fail to acquire = a flow/sync is already active → 409. + if (!entityLocker.LockPlex()) + { + return PlexLockedProblem(); + } + + // Only the Right/200 path keeps the lock held (handed off to the background pin flow). Release the + // controller-acquired lock on the Left branch AND on any thrown exception from the dispatch/enqueue + // (re-review finding 2 — StartPlexPinFlowHandler awaits a channel WriteAsync that can throw). + try + { + Either result = await mediator.Send(new StartPlexPinFlow(), cancellationToken); + return result.Match( + Left: error => + { + entityLocker.UnlockPlex(); + return error.ToErrorResult(); + }, + Right: url => (IActionResult)new OkObjectResult(new PlexPinFlowResponseModel(url))); + } + catch (Exception) + { + entityLocker.UnlockPlex(); + throw; + } + } + + [HttpPost("/api/media-sources/plex/sign-out", Name = "SignOutOfPlex")] + [Tags("Plex")] + [EndpointSummary("Sign out of Plex")] + [EndpointDescription( + "Purges all Plex servers, synced Plex content, and stored credentials. Acquires the Plex lock; the " + + "handler always releases it (even on failure).")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task SignOutOfPlex(CancellationToken cancellationToken) + { + if (!entityLocker.LockPlex()) + { + return PlexLockedProblem(); + } + + // SignOutOfPlexHandler is the designated releaser (unconditional finally) — the controller acquires + // and hands off; it does NOT release here (that would double-release on the handler's success path). + Either result = await mediator.Send(new SignOutOfPlex(), cancellationToken); + return result.ToDeletedResult(); + } + + [HttpGet("/api/media-sources/plex/{id:int}/libraries", Name = "GetPlexLibraries")] + [Tags("Plex")] + [EndpointSummary("Get a Plex server's libraries")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetLibraries(int id, CancellationToken cancellationToken) + { + if (!await PlexSourceExists(id, cancellationToken)) + { + return ApiResults.NotFoundProblem(); + } + + List libraries = + await mediator.Send(new GetPlexLibrariesBySourceId(id), cancellationToken); + return new OkObjectResult(libraries.Map(ToLibraryResponse).ToList()); + } + + [HttpPut("/api/media-sources/plex/{id:int}/libraries", Name = "ReplacePlexLibraryPreferences")] + [Tags("Plex")] + [EndpointSummary("Replace a Plex server's library sync preferences")] + [EndpointDescription( + "The body is the complete set of the source's libraries with each shouldSyncItems flag. A row absent " + + "from the request is left untouched. Every id must belong to this source (no Id=0 rows). Returns the " + + "reloaded list — library ids change when sync is disabled, so re-key any client draft off the response. " + + "Enabled libraries are queued for sync (locked libraries are skipped).")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task ReplaceLibraryPreferences( + int id, + [Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request, + CancellationToken cancellationToken) + { + if (!await PlexSourceExists(id, cancellationToken)) + { + return ApiResults.NotFoundProblem(); + } + + List libraries = request.Libraries ?? []; + List owned = + await mediator.Send(new GetPlexLibrariesBySourceId(id), cancellationToken); + var ownedIds = owned.Map(l => l.Id).ToHashSet(); + + // Source-scope the id set (§C4a / finding 2a): reject Id=0 rows and any id not owned by this source + // before dispatch — UpdatePlexLibraryPreferences carries no source id, so this is the only guard. + var invalidIds = libraries.Filter(l => l.Id < 1 || !ownedIds.Contains(l.Id)).Map(l => l.Id).ToList(); + if (invalidIds.Count > 0) + { + return BaseError.New($"Library {invalidIds[0]} does not belong to Plex media source {id}") + .ToErrorResult(); + } + + Either result = + await mediator.Send(request.ToPlexCommand(), cancellationToken); + if (result.IsLeft) + { + foreach (BaseError error in result.LeftToSeq()) + { + return error.ToErrorResult(); + } + } + + // Reload BEFORE enqueueing — ids change on disable (§C4a); the reloaded list carries the fresh ids + // for both the sync loop and the response (§7 write-path projection: reload via the GET's query). + List reloaded = + await mediator.Send(new GetPlexLibrariesBySourceId(id), cancellationToken); + + await EnqueuePostSaveSync(reloaded, cancellationToken); + + return new OkObjectResult(reloaded.Map(ToLibraryResponse).ToList()); + } + + [HttpGet("/api/media-sources/plex/{id:int}/path-replacements", Name = "GetPlexPathReplacements")] + [Tags("Plex")] + [EndpointSummary("Get a Plex server's path replacements")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetPathReplacements(int id, CancellationToken cancellationToken) + { + if (!await PlexSourceExists(id, cancellationToken)) + { + return ApiResults.NotFoundProblem(); + } + + List replacements = + await mediator.Send(new GetPlexPathReplacementsBySourceId(id), cancellationToken); + return new OkObjectResult(replacements.Map(ToPathReplacementResponse).ToList()); + } + + [HttpPut("/api/media-sources/plex/{id:int}/path-replacements", Name = "ReplacePlexPathReplacements")] + [Tags("Plex")] + [EndpointSummary("Replace a Plex server's path replacements")] + [EndpointDescription( + "Replaces the source's path replacements: an existing id updates, id<1 adds, an existing id absent from " + + "the body is deleted. Every positive id must belong to this source and every row needs a non-empty " + + "RemotePath and LocalPath (else 422, no partial mutation). Returns the reloaded list.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task ReplacePathReplacements( + int id, + [Required] [FromBody] ReplacePathReplacementsRequest request, + CancellationToken cancellationToken) + { + if (!await PlexSourceExists(id, cancellationToken)) + { + return ApiResults.NotFoundProblem(); + } + + // The handler owns the cross-source ownership guard + nonblank/null validation (→ 422, no mutation). + Either result = + await mediator.Send(request.ToPlexCommand(id), cancellationToken); + if (result.IsLeft) + { + foreach (BaseError error in result.LeftToSeq()) + { + return error.ToErrorResult(); + } + } + + List reloaded = + await mediator.Send(new GetPlexPathReplacementsBySourceId(id), cancellationToken); + return new OkObjectResult(reloaded.Map(ToPathReplacementResponse).ToList()); + } + + [HttpPost("/api/media-sources/plex/{id:int}/refresh-libraries", Name = "RefreshPlexLibraries")] + [Tags("Plex")] + [EndpointSummary("Refresh a Plex server's libraries")] + [EndpointDescription( + "Queues a synchronization of the server's libraries (fire-and-forget). Returns 409 while a Plex " + + "sign-in/sync lock is held; duplicate refreshes are accepted (the scan is idempotent).")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task RefreshLibraries(int id, CancellationToken cancellationToken) + { + if (!await PlexSourceExists(id, cancellationToken)) + { + return ApiResults.NotFoundProblem(); + } + + if (entityLocker.IsPlexLocked()) + { + return PlexLockedProblem(); + } + + await scannerWorkerChannel.WriteAsync(new SynchronizePlexLibraries(id), cancellationToken); + return new AcceptedResult(); + } + + private async Task PlexSourceExists(int id, CancellationToken cancellationToken) => + (await mediator.Send(new GetPlexMediaSourceById(id), cancellationToken)).IsSome; + + // Post-save re-sync (§C7 / finding 6): per enabled library, one LockLibrary ⇄ one release carried by + // the LAST message. Correcting the Blazor bug: the library message runs Unlock:false so the release + // rides SynchronizePlexNetworks(Unlock:true). Compensating-unlock if the networks enqueue throws. + private async Task EnqueuePostSaveSync( + IEnumerable libraries, + CancellationToken cancellationToken) + { + foreach (PlexLibraryViewModel library in libraries.Filter(l => l.ShouldSyncItems)) + { + if (!entityLocker.LockLibrary(library.Id)) + { + continue; + } + + try + { + await scannerWorkerChannel.WriteAsync( + new SynchronizePlexLibraryByIdIfNeeded(library.Id, Unlock: false), + cancellationToken); + await scannerWorkerChannel.WriteAsync( + new SynchronizePlexNetworks(library.Id, false, Unlock: true), + cancellationToken); + } + catch + { + // the library message carries Unlock:false, so the not-yet-enqueued networks message was + // the sole releaser — release here since it never ran + entityLocker.UnlockLibrary(library.Id); + throw; + } + } + } + + private static RemoteMediaSourceItemResponseModel ToItemResponse(PlexMediaSourceViewModel vm) => + new(vm.Id, vm.Name, vm.Address); + + private static RemoteLibraryResponseModel ToLibraryResponse(PlexLibraryViewModel vm) => + new(vm.Id, vm.Name, vm.MediaKind, vm.ShouldSyncItems); + + private static PathReplacementResponseModel ToPathReplacementResponse(PlexPathReplacementViewModel vm) => + new(vm.Id, vm.PlexPath, vm.LocalPath); +}