feat(api): Plex media-source write API + lock-lifecycle fixes (#202 slice S2)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -26,13 +26,23 @@ public class SignOutOfPlexHandler : IRequestHandler<SignOutOfPlex, Either<BaseEr
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(SignOutOfPlex request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 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<int> ids = await _mediaSourceRepository.DeleteAllPlex();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _plexSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockPlex();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockPlex();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,27 +8,41 @@ namespace ErsatzTV.Application.Plex;
|
||||
public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinFlow, Either<BaseError, bool>>
|
||||
{
|
||||
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IPlexTvApiClient _plexTvApiClient;
|
||||
|
||||
public TryCompletePlexPinFlowHandler(
|
||||
IPlexTvApiClient plexTvApiClient,
|
||||
ChannelWriter<IPlexBackgroundServiceRequest> channel)
|
||||
ChannelWriter<IPlexBackgroundServiceRequest> channel,
|
||||
IEntityLocker entityLocker)
|
||||
{
|
||||
_plexTvApiClient = plexTvApiClient;
|
||||
_channel = channel;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, bool>>
|
||||
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;
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -35,6 +50,17 @@ public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinF
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,14 +35,56 @@ public class
|
||||
private static PlexPathReplacement Project(PlexPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath };
|
||||
|
||||
private Task<Validation<BaseError, PlexMediaSource>> Validate(
|
||||
private async Task<Validation<BaseError, PlexMediaSource>> Validate(
|
||||
UpdatePlexPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
PlexMediaSourceMustExist(request, cancellationToken);
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlexMediaSource> maybeSource =
|
||||
await _mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken);
|
||||
|
||||
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
|
||||
foreach (PlexMediaSource plexMediaSource in maybeSource)
|
||||
{
|
||||
return ValidatePathReplacements(request, plexMediaSource);
|
||||
}
|
||||
|
||||
return Fail<BaseError, PlexMediaSource>(
|
||||
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<BaseError, PlexMediaSource> ValidatePathReplacements(
|
||||
UpdatePlexPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken)
|
||||
.Map(v => v.ToValidation<BaseError>($"Plex media source {request.PlexMediaSourceId} does not exist."));
|
||||
PlexMediaSource plexMediaSource)
|
||||
{
|
||||
List<PlexPathReplacementItem> items = request.PathReplacements;
|
||||
if (items is null)
|
||||
{
|
||||
return Fail<BaseError, PlexMediaSource>(BaseError.New("[PathReplacements] is required"));
|
||||
}
|
||||
|
||||
if (items.Any(i => i is null))
|
||||
{
|
||||
return Fail<BaseError, PlexMediaSource>(BaseError.New("[PathReplacements] must not contain null items"));
|
||||
}
|
||||
|
||||
if (items.Any(i => string.IsNullOrWhiteSpace(i.PlexPath) || string.IsNullOrWhiteSpace(i.LocalPath)))
|
||||
{
|
||||
return Fail<BaseError, PlexMediaSource>(
|
||||
BaseError.New("Each path replacement requires a non-empty RemotePath and LocalPath"));
|
||||
}
|
||||
|
||||
var ownedIds = Optional(plexMediaSource.PathReplacements).Flatten().Map(pr => pr.Id).ToHashSet();
|
||||
List<int> foreignIds = items.Filter(i => i.Id > 0 && !ownedIds.Contains(i.Id)).Map(i => i.Id).ToList();
|
||||
if (foreignIds.Count > 0)
|
||||
{
|
||||
return Fail<BaseError, PlexMediaSource>(
|
||||
BaseError.New(
|
||||
$"Path replacement {foreignIds[0]} does not belong to Plex media source {request.PlexMediaSourceId}"));
|
||||
}
|
||||
|
||||
return Success<BaseError, PlexMediaSource>(plexMediaSource);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,11 +396,14 @@ public class MediaSourceRepository(IDbContextFactory<TvContext> 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)
|
||||
|
||||
@@ -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<IPlexTvApiClient>();
|
||||
var channel = Substitute.For<ChannelWriter<IPlexBackgroundServiceRequest>>();
|
||||
IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
|
||||
|
||||
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<PlexAuthPin>())
|
||||
.Returns(_ =>
|
||||
{
|
||||
externalCts.Cancel();
|
||||
return false;
|
||||
});
|
||||
|
||||
var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker);
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(() =>
|
||||
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<IPlexTvApiClient>();
|
||||
var channel = Substitute.For<ChannelWriter<IPlexBackgroundServiceRequest>>();
|
||||
IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
|
||||
|
||||
plexTvApiClient.TryCompletePinFlow(Arg.Any<PlexAuthPin>())
|
||||
.Returns<bool>(_ => throw new InvalidOperationException("poll boom"));
|
||||
|
||||
var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
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<IPlexTvApiClient>();
|
||||
var channel = Substitute.For<ChannelWriter<IPlexBackgroundServiceRequest>>();
|
||||
IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
|
||||
|
||||
plexTvApiClient.TryCompletePinFlow(Arg.Any<PlexAuthPin>()).Returns(true);
|
||||
channel.WriteAsync(Arg.Any<IPlexBackgroundServiceRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns<ValueTask>(_ => throw new InvalidOperationException("enqueue boom"));
|
||||
|
||||
var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
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<IPlexTvApiClient>();
|
||||
var channel = Substitute.For<ChannelWriter<IPlexBackgroundServiceRequest>>();
|
||||
IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
|
||||
|
||||
plexTvApiClient.TryCompletePinFlow(Arg.Any<PlexAuthPin>()).Returns(true);
|
||||
|
||||
var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker);
|
||||
|
||||
Either<BaseError, bool> 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<SynchronizePlexMediaSources>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- SignOutOfPlexHandler: unconditional finally-release (§C6 / finding 7) -----
|
||||
|
||||
[Test]
|
||||
public async Task SignOut_Should_Release_Lock_When_Dependency_Throws()
|
||||
{
|
||||
IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
IPlexSecretStore plexSecretStore = Substitute.For<IPlexSecretStore>();
|
||||
IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
|
||||
ISearchIndex searchIndex = Substitute.For<ISearchIndex>();
|
||||
|
||||
mediaSourceRepository.DeleteAllPlex()
|
||||
.Returns<List<int>>(_ => throw new InvalidOperationException("delete boom"));
|
||||
|
||||
var handler = new SignOutOfPlexHandler(mediaSourceRepository, plexSecretStore, entityLocker, searchIndex);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
handler.Handle(new SignOutOfPlex(), CancellationToken.None));
|
||||
|
||||
entityLocker.Received(1).UnlockPlex();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SignOut_Should_Release_Lock_On_Success()
|
||||
{
|
||||
IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
IPlexSecretStore plexSecretStore = Substitute.For<IPlexSecretStore>();
|
||||
IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
|
||||
ISearchIndex searchIndex = Substitute.For<ISearchIndex>();
|
||||
|
||||
mediaSourceRepository.DeleteAllPlex().Returns(new List<int> { 1, 2 });
|
||||
|
||||
var handler = new SignOutOfPlexHandler(mediaSourceRepository, plexSecretStore, entityLocker, searchIndex);
|
||||
|
||||
Either<BaseError, Unit> 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<IMediaSourceRepository>();
|
||||
mediaSourceRepository.GetPlex(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlexMediaSource>.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<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(99, "/remote", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PathReplacements_Should_Reject_Blank_Rows_Without_Mutating()
|
||||
{
|
||||
IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
mediaSourceRepository.GetPlex(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlexMediaSource>.Some(SourceWithReplacement(1, replacementId: 5)));
|
||||
|
||||
var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(0, " ", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PathReplacements_Should_Reject_Null_Items_Without_Mutating()
|
||||
{
|
||||
IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
mediaSourceRepository.GetPlex(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlexMediaSource>.Some(SourceWithReplacement(1, replacementId: 5)));
|
||||
|
||||
var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdatePlexPathReplacements(1, [null]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PathReplacements_Should_Accept_Owned_Rows_And_Mutate()
|
||||
{
|
||||
IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
mediaSourceRepository.GetPlex(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlexMediaSource>.Some(SourceWithReplacement(1, replacementId: 5)));
|
||||
mediaSourceRepository.UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>())
|
||||
.Returns(Unit.Default);
|
||||
|
||||
var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository);
|
||||
|
||||
Either<BaseError, Unit> 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<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>());
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -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<IScannerBackgroundServiceRequest> _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<IMediator>();
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_plexSecretStore = Substitute.For<IPlexSecretStore>();
|
||||
_channel = Substitute.For<ChannelWriter<IScannerBackgroundServiceRequest>>();
|
||||
_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<GetAllPlexMediaSources>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexMediaSourceViewModel> { new(3, "Server", "http://plex:32400") });
|
||||
_plexSecretStore.GetUserAuthTokens()
|
||||
.Returns(new List<PlexUserAuthToken> { 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<GetAllPlexMediaSources>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexMediaSourceViewModel>());
|
||||
_plexSecretStore.GetUserAuthTokens().Returns(new List<PlexUserAuthToken>());
|
||||
|
||||
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<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<StartPlexPinFlow>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StartPinFlow_Should_Return_200_And_Hold_Lock_On_Success()
|
||||
{
|
||||
_entityLocker.LockPlex().Returns(true);
|
||||
_mediator.Send(Arg.Any<StartPlexPinFlow>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, string>("https://app.plex.tv/auth#?code=abc"));
|
||||
|
||||
IActionResult result = await _controller.StartPinFlow(CancellationToken.None);
|
||||
|
||||
var model = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlexPinFlowResponseModel>();
|
||||
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<StartPlexPinFlow>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, string>(BaseError.New("plex.tv error")));
|
||||
|
||||
IActionResult result = await _controller.StartPinFlow(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
_entityLocker.Received(1).UnlockPlex();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StartPinFlow_Should_Compensate_Unlock_On_Thrown_Dispatch()
|
||||
{
|
||||
_entityLocker.LockPlex().Returns(true);
|
||||
_mediator.Send(Arg.Any<StartPlexPinFlow>(), Arg.Any<CancellationToken>())
|
||||
.Returns<Either<BaseError, string>>(_ => throw new InvalidOperationException("channel closed"));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => _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<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<SignOutOfPlex>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SignOut_Should_Return_204_And_Not_Release_In_Controller()
|
||||
{
|
||||
_entityLocker.LockPlex().Returns(true);
|
||||
_mediator.Send(Arg.Any<SignOutOfPlex>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.SignOutOfPlex(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(Arg.Any<SignOutOfPlex>(), Arg.Any<CancellationToken>());
|
||||
// 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<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLibraries_Should_Return_200_List()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexLibraryViewModel> { new(5, "Movies", LibraryMediaKind.Movies, true) });
|
||||
|
||||
IActionResult result = await _controller.GetLibraries(3, CancellationToken.None);
|
||||
|
||||
var list = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<RemoteLibraryResponseModel>>();
|
||||
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<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlexLibraryPreferences>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraries_Should_Return_422_When_Id_Not_Owned()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexLibraryViewModel> { new(5, "Movies", LibraryMediaKind.Movies, true) });
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
3,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(999, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlexLibraryPreferences>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraries_Should_Return_422_For_Zero_Id_Row()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexLibraryViewModel> { new(5, "Movies", LibraryMediaKind.Movies, true) });
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
3,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(0, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlexLibraryPreferences>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexLibraryViewModel> { new(5, "Shows", LibraryMediaKind.Shows, true) });
|
||||
_mediator.Send(Arg.Any<UpdatePlexLibraryPreferences>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(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<OkObjectResult>().Value.ShouldBeOfType<List<RemoteLibraryResponseModel>>();
|
||||
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<SynchronizePlexLibraryByIdIfNeeded>(s => s.PlexLibraryId == 5 && !s.Unlock),
|
||||
Arg.Any<CancellationToken>());
|
||||
_channel.WriteAsync(
|
||||
Arg.Is<SynchronizePlexNetworks>(n => n.PlexLibraryId == 5 && n.Unlock),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraries_Should_Skip_Enqueue_For_Locked_Library()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexLibraryViewModel> { new(5, "Shows", LibraryMediaKind.Shows, true) });
|
||||
_mediator.Send(Arg.Any<UpdatePlexLibraryPreferences>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_entityLocker.LockLibrary(5).Returns(false);
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
3,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _channel.DidNotReceive().WriteAsync(
|
||||
Arg.Any<IScannerBackgroundServiceRequest>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- 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<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPathReplacements_Should_Map_Plex_Path_To_Remote_Path()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<GetPlexPathReplacementsBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexPathReplacementViewModel> { new(7, "/plex", "/local") });
|
||||
|
||||
IActionResult result = await _controller.GetPathReplacements(3, CancellationToken.None);
|
||||
|
||||
var list = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<PathReplacementResponseModel>>();
|
||||
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<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlexPathReplacements>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplacePathReplacements_Should_Return_422_On_Handler_Left()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<UpdatePlexPathReplacements>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("cross-source")));
|
||||
|
||||
IActionResult result = await _controller.ReplacePathReplacements(
|
||||
3,
|
||||
new ReplacePathReplacementsRequest([new PathReplacementItemRequest(99, "/remote", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplacePathReplacements_Should_Return_200_Reloaded_On_Success()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<UpdatePlexPathReplacements>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetPlexPathReplacementsBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexPathReplacementViewModel> { new(7, "/plex", "/local") });
|
||||
|
||||
IActionResult result = await _controller.ReplacePathReplacements(
|
||||
3,
|
||||
new ReplacePathReplacementsRequest([new PathReplacementItemRequest(7, "/plex", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
var list = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<PathReplacementResponseModel>>();
|
||||
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<NotFoundObjectResult>();
|
||||
await _channel.DidNotReceive().WriteAsync(
|
||||
Arg.Any<IScannerBackgroundServiceRequest>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _channel.DidNotReceive().WriteAsync(
|
||||
Arg.Any<IScannerBackgroundServiceRequest>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<AcceptedResult>();
|
||||
await _channel.Received(1).WriteAsync(
|
||||
Arg.Is<SynchronizePlexLibraries>(s => s.PlexMediaSourceId == 3),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private void SourceExists(bool exists) =>
|
||||
_mediator.Send(Arg.Any<GetPlexMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(exists
|
||||
? Option<PlexMediaSourceViewModel>.Some(new PlexMediaSourceViewModel(3, "Server", "http://plex:32400"))
|
||||
: Option<PlexMediaSourceViewModel>.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<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -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<IScannerBackgroundServiceRequest> 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<RemoteMediaSourceStateResponseModel> GetState(CancellationToken cancellationToken)
|
||||
{
|
||||
List<PlexMediaSourceViewModel> 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<IActionResult> 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<BaseError, string> 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<IActionResult> 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<BaseError, Unit> 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<RemoteLibraryResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetLibraries(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await PlexSourceExists(id, cancellationToken))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<PlexLibraryViewModel> 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<RemoteLibraryResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceLibraryPreferences(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await PlexSourceExists(id, cancellationToken))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<RemoteLibraryPreferenceRequest> libraries = request.Libraries ?? [];
|
||||
List<PlexLibraryViewModel> 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<BaseError, Unit> 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<PlexLibraryViewModel> 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<PathReplacementResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetPathReplacements(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await PlexSourceExists(id, cancellationToken))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<PlexPathReplacementViewModel> 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<PathReplacementResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> 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<BaseError, Unit> result =
|
||||
await mediator.Send(request.ToPlexCommand(id), cancellationToken);
|
||||
if (result.IsLeft)
|
||||
{
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
return error.ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
List<PlexPathReplacementViewModel> 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<IActionResult> 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<bool> 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<PlexLibraryViewModel> 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);
|
||||
}
|
||||
Reference in New Issue
Block a user