diff --git a/ErsatzTV.Application/Emby/Commands/DisconnectEmbyHandler.cs b/ErsatzTV.Application/Emby/Commands/DisconnectEmbyHandler.cs index 9f75026c6..0fc4a26b4 100644 --- a/ErsatzTV.Application/Emby/Commands/DisconnectEmbyHandler.cs +++ b/ErsatzTV.Application/Emby/Commands/DisconnectEmbyHandler.cs @@ -30,12 +30,21 @@ public class DisconnectEmbyHandler : IRequestHandler ids = await _mediaSourceRepository.DeleteAllEmby(); - await _searchIndex.RemoveItems(ids); - _searchIndex.Commit(); - await _embySecretStore.DeleteAll(); - _entityLocker.UnlockRemoteMediaSource(); + // This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) — + // release unconditionally so a throw from any awaited dependency (repo delete, search-index + // commit, secret store) can't wedge the Emby lock until restart (design #202 finding 7). + try + { + List ids = await _mediaSourceRepository.DeleteAllEmby(); + await _searchIndex.RemoveItems(ids); + _searchIndex.Commit(); + await _embySecretStore.DeleteAll(); - return Unit.Default; + return Unit.Default; + } + finally + { + _entityLocker.UnlockRemoteMediaSource(); + } } } diff --git a/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacementsHandler.cs b/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacementsHandler.cs index 66661fbed..33c3964c5 100644 --- a/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacementsHandler.cs +++ b/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacementsHandler.cs @@ -1,4 +1,4 @@ -using ErsatzTV.Core; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; @@ -12,12 +12,29 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler _mediaSourceRepository = mediaSourceRepository; - public Task> Handle( + public async Task> Handle( UpdateEmbyPathReplacements request, - CancellationToken cancellationToken) => - Validate(request, cancellationToken) - .MapT(pms => MergePathReplacements(request, pms)) - .Bind(v => v.ToEitherAsync()); + CancellationToken cancellationToken) + { + Option maybeSource = + await _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken); + + return await maybeSource.Match( + Some: async embyMediaSource => + { + Option maybeError = ValidateItems(request, embyMediaSource); + return await maybeError.Match( + Some: error => Task.FromResult(Left(error)), + None: async () => + { + await MergePathReplacements(request, embyMediaSource); + return Right(Unit.Default); + }); + }, + None: () => Task.FromResult( + Left( + BaseError.New($"Emby media source {request.EmbyMediaSourceId} does not exist.")))); + } private Task MergePathReplacements( UpdateEmbyPathReplacements request, @@ -37,12 +54,38 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath }; - private Task> Validate(UpdateEmbyPathReplacements request, CancellationToken cancellationToken) => - EmbyMediaSourceMustExist(request, cancellationToken); + // Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by + // EmbyMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the + // mutation is all-or-nothing. + private static Option ValidateItems( + UpdateEmbyPathReplacements request, + EmbyMediaSource embyMediaSource) + { + List items = request.PathReplacements ?? []; - private Task> EmbyMediaSourceMustExist( - UpdateEmbyPathReplacements request, CancellationToken cancellationToken) => - _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken) - .Map(v => v.ToValidation( - $"Emby media source {request.EmbyMediaSourceId} does not exist.")); + if (items.Any(item => item is null)) + { + return BaseError.New("Path replacement items must not be null."); + } + + if (items.Any(item => string.IsNullOrWhiteSpace(item.EmbyPath) || string.IsNullOrWhiteSpace(item.LocalPath))) + { + return BaseError.New("Each path replacement requires a non-blank Emby path and local path."); + } + + var existingIds = (embyMediaSource.PathReplacements ?? new List()) + .Map(pr => pr.Id) + .ToList(); + var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id)) + .Map(item => item.Id) + .ToList(); + if (foreignIds.Count > 0) + { + return BaseError.New( + $"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Emby media source " + + $"{request.EmbyMediaSourceId}."); + } + + return Option.None; + } } diff --git a/ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs b/ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs index babacadcc..b533cc3f7 100644 --- a/ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs +++ b/ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs @@ -30,12 +30,21 @@ public class DisconnectJellyfinHandler : IRequestHandler ids = await _mediaSourceRepository.DeleteAllJellyfin(); - await _searchIndex.RemoveItems(ids); - _searchIndex.Commit(); - await _jellyfinSecretStore.DeleteAll(); - _entityLocker.UnlockRemoteMediaSource(); + // This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) — + // release unconditionally so a throw from any awaited dependency (repo delete, search-index + // commit, secret store) can't wedge the Jellyfin lock until restart (design #202 finding 7). + try + { + List ids = await _mediaSourceRepository.DeleteAllJellyfin(); + await _searchIndex.RemoveItems(ids); + _searchIndex.Commit(); + await _jellyfinSecretStore.DeleteAll(); - return Unit.Default; + return Unit.Default; + } + finally + { + _entityLocker.UnlockRemoteMediaSource(); + } } } diff --git a/ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinPathReplacementsHandler.cs b/ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinPathReplacementsHandler.cs index 9ac74f1eb..c74408f09 100644 --- a/ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinPathReplacementsHandler.cs +++ b/ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinPathReplacementsHandler.cs @@ -1,4 +1,4 @@ -using ErsatzTV.Core; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; @@ -12,12 +12,29 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler _mediaSourceRepository = mediaSourceRepository; - public Task> Handle( + public async Task> Handle( UpdateJellyfinPathReplacements request, - CancellationToken cancellationToken) => - Validate(request) - .MapT(pms => MergePathReplacements(request, pms)) - .Bind(v => v.ToEitherAsync()); + CancellationToken cancellationToken) + { + Option maybeSource = + await _mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId); + + return await maybeSource.Match( + Some: async jellyfinMediaSource => + { + Option maybeError = ValidateItems(request, jellyfinMediaSource); + return await maybeError.Match( + Some: error => Task.FromResult(Left(error)), + None: async () => + { + await MergePathReplacements(request, jellyfinMediaSource); + return Right(Unit.Default); + }); + }, + None: () => Task.FromResult( + Left( + BaseError.New($"Jellyfin media source {request.JellyfinMediaSourceId} does not exist.")))); + } private Task MergePathReplacements( UpdateJellyfinPathReplacements request, @@ -37,12 +54,38 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler new() { Id = vm.Id, JellyfinPath = vm.JellyfinPath, LocalPath = vm.LocalPath }; - private Task> Validate(UpdateJellyfinPathReplacements request) => - JellyfinMediaSourceMustExist(request); + // Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by + // JellyfinMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the + // mutation is all-or-nothing. + private static Option ValidateItems( + UpdateJellyfinPathReplacements request, + JellyfinMediaSource jellyfinMediaSource) + { + List items = request.PathReplacements ?? []; - private Task> JellyfinMediaSourceMustExist( - UpdateJellyfinPathReplacements request) => - _mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId) - .Map(v => v.ToValidation( - $"Jellyfin media source {request.JellyfinMediaSourceId} does not exist.")); + if (items.Any(item => item is null)) + { + return BaseError.New("Path replacement items must not be null."); + } + + if (items.Any(item => string.IsNullOrWhiteSpace(item.JellyfinPath) || string.IsNullOrWhiteSpace(item.LocalPath))) + { + return BaseError.New("Each path replacement requires a non-blank Jellyfin path and local path."); + } + + var existingIds = (jellyfinMediaSource.PathReplacements ?? new List()) + .Map(pr => pr.Id) + .ToList(); + var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id)) + .Map(item => item.Id) + .ToList(); + if (foreignIds.Count > 0) + { + return BaseError.New( + $"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Jellyfin media source " + + $"{request.JellyfinMediaSourceId}."); + } + + return Option.None; + } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs index 05df5b4fc..a66b0d7cf 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -695,8 +695,14 @@ public class MediaSourceRepository(IDbContextFactory dbContextFactory await dbContext.Connection.ExecuteAsync( @"UPDATE JellyfinPathReplacement SET JellyfinPath = @JellyfinPath, LocalPath = @LocalPath - WHERE Id = @Id", - new { update.JellyfinPath, update.LocalPath, update.Id }); + WHERE Id = @Id AND JellyfinMediaSourceId = @JellyfinMediaSourceId", + new + { + update.JellyfinPath, + update.LocalPath, + update.Id, + JellyfinMediaSourceId = jellyfinMediaSourceId + }); } foreach (JellyfinPathReplacement delete in toDelete) @@ -893,8 +899,14 @@ public class MediaSourceRepository(IDbContextFactory dbContextFactory await dbContext.Connection.ExecuteAsync( @"UPDATE EmbyPathReplacement SET EmbyPath = @EmbyPath, LocalPath = @LocalPath - WHERE Id = @Id", - new { update.EmbyPath, update.LocalPath, update.Id }); + WHERE Id = @Id AND EmbyMediaSourceId = @EmbyMediaSourceId", + new + { + update.EmbyPath, + update.LocalPath, + update.Id, + EmbyMediaSourceId = embyMediaSourceId + }); } foreach (EmbyPathReplacement delete in toDelete) diff --git a/ErsatzTV.Tests/Application/Emby/DisconnectEmbyHandlerTests.cs b/ErsatzTV.Tests/Application/Emby/DisconnectEmbyHandlerTests.cs new file mode 100644 index 000000000..4efd4eab6 --- /dev/null +++ b/ErsatzTV.Tests/Application/Emby/DisconnectEmbyHandlerTests.cs @@ -0,0 +1,85 @@ +using ErsatzTV.Application.Emby; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using LanguageExt; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Emby; + +[TestFixture] +public class DisconnectEmbyHandlerTests +{ + private IMediaSourceRepository _mediaSourceRepository = null!; + private IEmbySecretStore _embySecretStore = null!; + private IEntityLocker _entityLocker = null!; + private ISearchIndex _searchIndex = null!; + private DisconnectEmbyHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _mediaSourceRepository = Substitute.For(); + _embySecretStore = Substitute.For(); + _entityLocker = Substitute.For(); + _searchIndex = Substitute.For(); + _handler = new DisconnectEmbyHandler( + _mediaSourceRepository, + _embySecretStore, + _entityLocker, + _searchIndex); + } + + [Test] + public async Task Handle_Should_Release_Lock_On_Success() + { + _mediaSourceRepository.DeleteAllEmby().Returns(new List()); + + Either result = await _handler.Handle(new DisconnectEmby(), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } + + [Test] + public async Task Handle_Should_Release_Lock_When_Repository_Delete_Throws() + { + _mediaSourceRepository.DeleteAllEmby().ThrowsAsync(new InvalidOperationException("db exploded")); + + await Should.ThrowAsync( + async () => await _handler.Handle(new DisconnectEmby(), CancellationToken.None)); + + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } + + [Test] + public async Task Handle_Should_Release_Lock_When_SearchIndex_RemoveItems_Throws() + { + _mediaSourceRepository.DeleteAllEmby().Returns([1, 2]); + _searchIndex.RemoveItems(Arg.Any>()).ThrowsAsync(new InvalidOperationException("index down")); + + await Should.ThrowAsync( + async () => await _handler.Handle(new DisconnectEmby(), CancellationToken.None)); + + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } + + [Test] + public async Task Handle_Should_Release_Lock_When_SecretStore_DeleteAll_Throws() + { + _mediaSourceRepository.DeleteAllEmby().Returns(new List()); + _embySecretStore.DeleteAll().ThrowsAsync(new InvalidOperationException("secret store down")); + + await Should.ThrowAsync( + async () => await _handler.Handle(new DisconnectEmby(), CancellationToken.None)); + + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } +} diff --git a/ErsatzTV.Tests/Application/Emby/UpdateEmbyPathReplacementsHandlerTests.cs b/ErsatzTV.Tests/Application/Emby/UpdateEmbyPathReplacementsHandlerTests.cs new file mode 100644 index 000000000..35781b8a7 --- /dev/null +++ b/ErsatzTV.Tests/Application/Emby/UpdateEmbyPathReplacementsHandlerTests.cs @@ -0,0 +1,136 @@ +using ErsatzTV.Application.Emby; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Emby; + +[TestFixture] +public class UpdateEmbyPathReplacementsHandlerTests +{ + private IMediaSourceRepository _mediaSourceRepository = null!; + private UpdateEmbyPathReplacementsHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _mediaSourceRepository = Substitute.For(); + _handler = new UpdateEmbyPathReplacementsHandler(_mediaSourceRepository); + } + + [Test] + public async Task Handle_Should_Return_422_When_Source_Does_Not_Exist() + { + _mediaSourceRepository.GetEmby(99, Arg.Any()).Returns(Option.None); + + Either result = await _handler.Handle( + new UpdateEmbyPathReplacements(99, [new EmbyPathReplacementItem(0, "/emby", "/local")]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task Handle_Should_Return_422_For_Cross_Source_Id_And_Perform_No_Mutation() + { + var source = new EmbyMediaSource + { + Id = 1, + PathReplacements = [new EmbyPathReplacement { Id = 1, EmbyPath = "/emby1", LocalPath = "/l1" }] + }; + _mediaSourceRepository.GetEmby(1, Arg.Any()).Returns(Option.Some(source)); + + // id 999 belongs to some other source, not this one + Either result = await _handler.Handle( + new UpdateEmbyPathReplacements(1, [new EmbyPathReplacementItem(999, "/emby", "/local")]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [TestCase("", "/local")] + [TestCase("/emby", "")] + [TestCase(" ", " ")] + public async Task Handle_Should_Return_422_For_Blank_Paths(string embyPath, string localPath) + { + var source = new EmbyMediaSource { Id = 1, PathReplacements = [] }; + _mediaSourceRepository.GetEmby(1, Arg.Any()).Returns(Option.Some(source)); + + Either result = await _handler.Handle( + new UpdateEmbyPathReplacements(1, [new EmbyPathReplacementItem(0, embyPath, localPath)]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task Handle_Should_Return_422_For_Null_Items_List_Defense() + { + var source = new EmbyMediaSource { Id = 1, PathReplacements = [] }; + _mediaSourceRepository.GetEmby(1, Arg.Any()).Returns(Option.Some(source)); + + Either result = await _handler.Handle( + new UpdateEmbyPathReplacements(1, [null!]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + } + + [Test] + public async Task Handle_Should_Merge_Add_Update_Delete_On_Valid_Request() + { + var source = new EmbyMediaSource + { + Id = 1, + PathReplacements = + [ + new EmbyPathReplacement { Id = 1, EmbyPath = "/old", LocalPath = "/old-local" }, + new EmbyPathReplacement { Id = 2, EmbyPath = "/gone", LocalPath = "/gone-local" } + ] + }; + _mediaSourceRepository.GetEmby(1, Arg.Any()).Returns(Option.Some(source)); + _mediaSourceRepository.UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()) + .Returns(Unit.Default); + + Either result = await _handler.Handle( + new UpdateEmbyPathReplacements( + 1, + [ + new EmbyPathReplacementItem(1, "/updated", "/updated-local"), // update + new EmbyPathReplacementItem(0, "/new", "/new-local") // add + // id 2 is absent -> delete + ]), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + await _mediaSourceRepository.Received(1).UpdatePathReplacements( + 1, + Arg.Is>(l => l.Count == 1 && l[0].EmbyPath == "/new"), + Arg.Is>(l => l.Count == 1 && l[0].Id == 1 && l[0].EmbyPath == "/updated"), + Arg.Is>(l => l.Count == 1 && l[0].Id == 2)); + } +} diff --git a/ErsatzTV.Tests/Application/Jellyfin/DisconnectJellyfinHandlerTests.cs b/ErsatzTV.Tests/Application/Jellyfin/DisconnectJellyfinHandlerTests.cs new file mode 100644 index 000000000..a9367962e --- /dev/null +++ b/ErsatzTV.Tests/Application/Jellyfin/DisconnectJellyfinHandlerTests.cs @@ -0,0 +1,85 @@ +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using LanguageExt; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Jellyfin; + +[TestFixture] +public class DisconnectJellyfinHandlerTests +{ + private IMediaSourceRepository _mediaSourceRepository = null!; + private IJellyfinSecretStore _jellyfinSecretStore = null!; + private IEntityLocker _entityLocker = null!; + private ISearchIndex _searchIndex = null!; + private DisconnectJellyfinHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _mediaSourceRepository = Substitute.For(); + _jellyfinSecretStore = Substitute.For(); + _entityLocker = Substitute.For(); + _searchIndex = Substitute.For(); + _handler = new DisconnectJellyfinHandler( + _mediaSourceRepository, + _jellyfinSecretStore, + _entityLocker, + _searchIndex); + } + + [Test] + public async Task Handle_Should_Release_Lock_On_Success() + { + _mediaSourceRepository.DeleteAllJellyfin().Returns(new List()); + + Either result = await _handler.Handle(new DisconnectJellyfin(), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } + + [Test] + public async Task Handle_Should_Release_Lock_When_Repository_Delete_Throws() + { + _mediaSourceRepository.DeleteAllJellyfin().ThrowsAsync(new InvalidOperationException("db exploded")); + + await Should.ThrowAsync( + async () => await _handler.Handle(new DisconnectJellyfin(), CancellationToken.None)); + + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } + + [Test] + public async Task Handle_Should_Release_Lock_When_SearchIndex_RemoveItems_Throws() + { + _mediaSourceRepository.DeleteAllJellyfin().Returns([1, 2]); + _searchIndex.RemoveItems(Arg.Any>()).ThrowsAsync(new InvalidOperationException("index down")); + + await Should.ThrowAsync( + async () => await _handler.Handle(new DisconnectJellyfin(), CancellationToken.None)); + + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } + + [Test] + public async Task Handle_Should_Release_Lock_When_SecretStore_DeleteAll_Throws() + { + _mediaSourceRepository.DeleteAllJellyfin().Returns(new List()); + _jellyfinSecretStore.DeleteAll().ThrowsAsync(new InvalidOperationException("secret store down")); + + await Should.ThrowAsync( + async () => await _handler.Handle(new DisconnectJellyfin(), CancellationToken.None)); + + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } +} diff --git a/ErsatzTV.Tests/Application/Jellyfin/UpdateJellyfinPathReplacementsHandlerTests.cs b/ErsatzTV.Tests/Application/Jellyfin/UpdateJellyfinPathReplacementsHandlerTests.cs new file mode 100644 index 000000000..ebf10c34d --- /dev/null +++ b/ErsatzTV.Tests/Application/Jellyfin/UpdateJellyfinPathReplacementsHandlerTests.cs @@ -0,0 +1,136 @@ +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Jellyfin; + +[TestFixture] +public class UpdateJellyfinPathReplacementsHandlerTests +{ + private IMediaSourceRepository _mediaSourceRepository = null!; + private UpdateJellyfinPathReplacementsHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _mediaSourceRepository = Substitute.For(); + _handler = new UpdateJellyfinPathReplacementsHandler(_mediaSourceRepository); + } + + [Test] + public async Task Handle_Should_Return_422_When_Source_Does_Not_Exist() + { + _mediaSourceRepository.GetJellyfin(99).Returns(Option.None); + + Either result = await _handler.Handle( + new UpdateJellyfinPathReplacements(99, [new JellyfinPathReplacementItem(0, "/jf", "/local")]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task Handle_Should_Return_422_For_Cross_Source_Id_And_Perform_No_Mutation() + { + var source = new JellyfinMediaSource + { + Id = 1, + PathReplacements = [new JellyfinPathReplacement { Id = 1, JellyfinPath = "/jf1", LocalPath = "/l1" }] + }; + _mediaSourceRepository.GetJellyfin(1).Returns(Option.Some(source)); + + // id 999 belongs to some other source, not this one + Either result = await _handler.Handle( + new UpdateJellyfinPathReplacements(1, [new JellyfinPathReplacementItem(999, "/jf", "/local")]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [TestCase("", "/local")] + [TestCase("/jf", "")] + [TestCase(" ", " ")] + public async Task Handle_Should_Return_422_For_Blank_Paths(string jellyfinPath, string localPath) + { + var source = new JellyfinMediaSource { Id = 1, PathReplacements = [] }; + _mediaSourceRepository.GetJellyfin(1).Returns(Option.Some(source)); + + Either result = await _handler.Handle( + new UpdateJellyfinPathReplacements(1, [new JellyfinPathReplacementItem(0, jellyfinPath, localPath)]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task Handle_Should_Return_422_For_Null_Items_List_Defense() + { + var source = new JellyfinMediaSource { Id = 1, PathReplacements = [] }; + _mediaSourceRepository.GetJellyfin(1).Returns(Option.Some(source)); + + Either result = await _handler.Handle( + new UpdateJellyfinPathReplacements(1, [null!]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + } + + [Test] + public async Task Handle_Should_Merge_Add_Update_Delete_On_Valid_Request() + { + var source = new JellyfinMediaSource + { + Id = 1, + PathReplacements = + [ + new JellyfinPathReplacement { Id = 1, JellyfinPath = "/old", LocalPath = "/old-local" }, + new JellyfinPathReplacement { Id = 2, JellyfinPath = "/gone", LocalPath = "/gone-local" } + ] + }; + _mediaSourceRepository.GetJellyfin(1).Returns(Option.Some(source)); + _mediaSourceRepository.UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()) + .Returns(Unit.Default); + + Either result = await _handler.Handle( + new UpdateJellyfinPathReplacements( + 1, + [ + new JellyfinPathReplacementItem(1, "/updated", "/updated-local"), // update + new JellyfinPathReplacementItem(0, "/new", "/new-local") // add + // id 2 is absent -> delete + ]), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + await _mediaSourceRepository.Received(1).UpdatePathReplacements( + 1, + Arg.Is>(l => l.Count == 1 && l[0].JellyfinPath == "/new"), + Arg.Is>(l => l.Count == 1 && l[0].Id == 1 && l[0].JellyfinPath == "/updated"), + Arg.Is>(l => l.Count == 1 && l[0].Id == 2)); + } +} diff --git a/ErsatzTV.Tests/Controllers/EmbyMediaSourcesControllerTests.cs b/ErsatzTV.Tests/Controllers/EmbyMediaSourcesControllerTests.cs new file mode 100644 index 000000000..d5a672677 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/EmbyMediaSourcesControllerTests.cs @@ -0,0 +1,426 @@ +using System.Reflection; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Emby; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Locking; +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 EmbyMediaSourcesControllerTests +{ + private IMediator _mediator = null!; + private IEntityLocker _entityLocker = null!; + private Channel _scannerChannel = null!; + private EmbyMediaSourcesController _controller = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _entityLocker = Substitute.For(); + _scannerChannel = System.Threading.Channels.Channel.CreateUnbounded(); + _controller = new EmbyMediaSourcesController(_mediator, _entityLocker, _scannerChannel.Writer); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(EmbyMediaSourcesController.GetState), "GET", "/api/media-sources/emby"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.GetConnection), + "GET", + "/api/media-sources/emby/connection"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.SaveConnection), + "PUT", + "/api/media-sources/emby/connection"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.Disconnect), + "POST", + "/api/media-sources/emby/disconnect"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.GetLibraries), + "GET", + "/api/media-sources/emby/{id:int}/libraries"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.ReplaceLibraryPreferences), + "PUT", + "/api/media-sources/emby/{id:int}/libraries"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.GetPathReplacements), + "GET", + "/api/media-sources/emby/{id:int}/path-replacements"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.ReplacePathReplacements), + "PUT", + "/api/media-sources/emby/{id:int}/path-replacements"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.RefreshLibraries), + "POST", + "/api/media-sources/emby/{id:int}/refresh-libraries"); + } + + [Test] + public async Task GetState_Should_Report_Authorized_And_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new EmbyMediaSourceViewModel(1, "My Server", "http://emby.local")]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new EmbySecrets { Address = "http://emby.local", ApiKey = "secret" }); + _entityLocker.IsRemoteMediaSourceLocked().Returns(true); + + RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); + + result.IsAuthorized.ShouldBeTrue(); + result.IsLocked.ShouldBeTrue(); + result.Servers.ShouldBe([new RemoteMediaSourceItemResponseModel(1, "My Server", "http://emby.local")]); + } + + [Test] + public async Task GetState_Should_Report_Unauthorized_When_ApiKey_Blank() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List()); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new EmbySecrets { Address = "http://emby.local", ApiKey = "" }); + + RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); + + result.IsAuthorized.ShouldBeFalse(); + } + + [Test] + public async Task GetConnection_Should_Never_Return_The_Api_Key() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new EmbySecrets { Address = "http://emby.local", ApiKey = "super-secret" }); + + RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None); + + result.Address.ShouldBe("http://emby.local"); + result.HasApiKey.ShouldBeTrue(); + result.ToString().ShouldNotContain("super-secret"); + } + + [Test] + public async Task GetConnection_Should_Report_No_Key_When_Not_Configured() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new EmbySecrets { Address = "", ApiKey = "" }); + + RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None); + + result.HasApiKey.ShouldBeFalse(); + } + + [Test] + public async Task SaveConnection_Should_Return_409_When_Locked() + { + _entityLocker.IsRemoteMediaSourceLocked().Returns(true); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://emby.local", "key"), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Return_422_For_Non_Absolute_Address() + { + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("not-a-uri", "key"), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Return_422_When_Key_Blank_On_First_Connect() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new EmbySecrets { Address = "", ApiKey = "" }); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://emby.local", ""), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Retain_Existing_Key_When_Blank() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + new EmbySecrets { Address = "http://old.local", ApiKey = "existing-key" }, + new EmbySecrets { Address = "http://emby.local", ApiKey = "existing-key" }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://emby.local", " "), + CancellationToken.None); + + result.ShouldBeOfType().Value + .ShouldBeOfType() + .HasApiKey.ShouldBeTrue(); + await _mediator.Received(1).Send( + Arg.Is(c => c.Secrets.ApiKey == "existing-key" && c.Secrets.Address == "http://emby.local"), + Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Set_New_Key_When_NonBlank() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + new EmbySecrets { Address = "http://old.local", ApiKey = "existing-key" }, + new EmbySecrets { Address = "http://emby.local", ApiKey = "new-key" }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://emby.local", "new-key"), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.Secrets.ApiKey == "new-key"), + Arg.Any()); + } + + [Test] + public async Task Disconnect_Should_Return_409_When_Lock_Fails() + { + _entityLocker.LockRemoteMediaSource().Returns(false); + + IActionResult result = await _controller.Disconnect(CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Disconnect_Should_Return_204_On_Success() + { + _entityLocker.LockRemoteMediaSource().Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Disconnect(CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetLibraries_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetLibraries(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Return_422_For_Foreign_Id() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(999, true)]), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Return_422_When_Not_Covering_All_Libraries() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + [ + new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1), + new EmbyLibraryViewModel(2, "Shows", LibraryMediaKind.Shows, true, 1) + ]); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Enqueue_Sync_Pair_For_Enabled_Libraries_And_Return_Reloaded() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + [new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)], + [new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _entityLocker.LockLibrary(1).Returns(true); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), + CancellationToken.None); + + result.ShouldBeOfType().Value + .ShouldBeOfType>() + .Single().Id.ShouldBe(1); + + _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? first).ShouldBeTrue(); + first.ShouldBeOfType().EmbyMediaSourceId.ShouldBe(1); + _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? second).ShouldBeTrue(); + second.ShouldBeOfType().EmbyLibraryId.ShouldBe(1); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Skip_Enqueue_When_Library_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + [new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)], + [new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _entityLocker.LockLibrary(1).Returns(false); + + await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), + CancellationToken.None); + + _scannerChannel.Reader.TryRead(out _).ShouldBeFalse(); + } + + [Test] + public async Task GetPathReplacements_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetPathReplacements(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task ReplacePathReplacements_Should_Return_200_With_Reloaded_List() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new EmbyPathReplacementViewModel(1, "/emby", "/local")]); + + IActionResult result = await _controller.ReplacePathReplacements( + 1, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(1, "/emby", "/local")]), + CancellationToken.None); + + List body = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + body.Single().ShouldBe(new PathReplacementResponseModel(1, "/emby", "/local")); + } + + [Test] + public async Task ReplacePathReplacements_Should_Map_Handler_Error_To_422() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad row"))); + + IActionResult result = await _controller.ReplacePathReplacements( + 1, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(999, "", "")]), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + } + + [Test] + public async Task RefreshLibraries_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.RefreshLibraries(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task RefreshLibraries_Should_Return_409_When_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _entityLocker.IsRemoteMediaSourceLocked().Returns(true); + + IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + } + + [Test] + public async Task RefreshLibraries_Should_Enqueue_And_Return_202() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _entityLocker.IsRemoteMediaSourceLocked().Returns(false); + + IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None); + + result.ShouldBeOfType(); + _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue(); + request.ShouldBeOfType().EmbyMediaSourceId.ShouldBe(1); + } + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(EmbyMediaSourcesController).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.Tests/Controllers/JellyfinMediaSourcesControllerTests.cs b/ErsatzTV.Tests/Controllers/JellyfinMediaSourcesControllerTests.cs new file mode 100644 index 000000000..5623b5dfa --- /dev/null +++ b/ErsatzTV.Tests/Controllers/JellyfinMediaSourcesControllerTests.cs @@ -0,0 +1,426 @@ +using System.Reflection; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Jellyfin; +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 JellyfinMediaSourcesControllerTests +{ + private IMediator _mediator = null!; + private IEntityLocker _entityLocker = null!; + private Channel _scannerChannel = null!; + private JellyfinMediaSourcesController _controller = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _entityLocker = Substitute.For(); + _scannerChannel = System.Threading.Channels.Channel.CreateUnbounded(); + _controller = new JellyfinMediaSourcesController(_mediator, _entityLocker, _scannerChannel.Writer); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(JellyfinMediaSourcesController.GetState), "GET", "/api/media-sources/jellyfin"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.GetConnection), + "GET", + "/api/media-sources/jellyfin/connection"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.SaveConnection), + "PUT", + "/api/media-sources/jellyfin/connection"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.Disconnect), + "POST", + "/api/media-sources/jellyfin/disconnect"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.GetLibraries), + "GET", + "/api/media-sources/jellyfin/{id:int}/libraries"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.ReplaceLibraryPreferences), + "PUT", + "/api/media-sources/jellyfin/{id:int}/libraries"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.GetPathReplacements), + "GET", + "/api/media-sources/jellyfin/{id:int}/path-replacements"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.ReplacePathReplacements), + "PUT", + "/api/media-sources/jellyfin/{id:int}/path-replacements"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.RefreshLibraries), + "POST", + "/api/media-sources/jellyfin/{id:int}/refresh-libraries"); + } + + [Test] + public async Task GetState_Should_Report_Authorized_And_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new JellyfinMediaSourceViewModel(1, "My Server", "http://jf.local")]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new JellyfinSecrets { Address = "http://jf.local", ApiKey = "secret" }); + _entityLocker.IsRemoteMediaSourceLocked().Returns(true); + + RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); + + result.IsAuthorized.ShouldBeTrue(); + result.IsLocked.ShouldBeTrue(); + result.Servers.ShouldBe([new RemoteMediaSourceItemResponseModel(1, "My Server", "http://jf.local")]); + } + + [Test] + public async Task GetState_Should_Report_Unauthorized_When_ApiKey_Blank() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List()); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new JellyfinSecrets { Address = "http://jf.local", ApiKey = "" }); + + RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); + + result.IsAuthorized.ShouldBeFalse(); + } + + [Test] + public async Task GetConnection_Should_Never_Return_The_Api_Key() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new JellyfinSecrets { Address = "http://jf.local", ApiKey = "super-secret" }); + + RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None); + + result.Address.ShouldBe("http://jf.local"); + result.HasApiKey.ShouldBeTrue(); + result.ToString().ShouldNotContain("super-secret"); + } + + [Test] + public async Task GetConnection_Should_Report_No_Key_When_Not_Configured() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new JellyfinSecrets { Address = "", ApiKey = "" }); + + RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None); + + result.HasApiKey.ShouldBeFalse(); + } + + [Test] + public async Task SaveConnection_Should_Return_409_When_Locked() + { + _entityLocker.IsRemoteMediaSourceLocked().Returns(true); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://jf.local", "key"), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Return_422_For_Non_Absolute_Address() + { + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("not-a-uri", "key"), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Return_422_When_Key_Blank_On_First_Connect() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new JellyfinSecrets { Address = "", ApiKey = "" }); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://jf.local", ""), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Retain_Existing_Key_When_Blank() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + new JellyfinSecrets { Address = "http://old.local", ApiKey = "existing-key" }, + new JellyfinSecrets { Address = "http://jf.local", ApiKey = "existing-key" }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://jf.local", " "), + CancellationToken.None); + + result.ShouldBeOfType().Value + .ShouldBeOfType() + .HasApiKey.ShouldBeTrue(); + await _mediator.Received(1).Send( + Arg.Is(c => c.Secrets.ApiKey == "existing-key" && c.Secrets.Address == "http://jf.local"), + Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Set_New_Key_When_NonBlank() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + new JellyfinSecrets { Address = "http://old.local", ApiKey = "existing-key" }, + new JellyfinSecrets { Address = "http://jf.local", ApiKey = "new-key" }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://jf.local", "new-key"), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.Secrets.ApiKey == "new-key"), + Arg.Any()); + } + + [Test] + public async Task Disconnect_Should_Return_409_When_Lock_Fails() + { + _entityLocker.LockRemoteMediaSource().Returns(false); + + IActionResult result = await _controller.Disconnect(CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Disconnect_Should_Return_204_On_Success() + { + _entityLocker.LockRemoteMediaSource().Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Disconnect(CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetLibraries_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetLibraries(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Return_422_For_Foreign_Id() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(999, true)]), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Return_422_When_Not_Covering_All_Libraries() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + [ + new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1), + new JellyfinLibraryViewModel(2, "Shows", LibraryMediaKind.Shows, true, 1) + ]); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Enqueue_Sync_Pair_For_Enabled_Libraries_And_Return_Reloaded() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + [new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)], + [new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _entityLocker.LockLibrary(1).Returns(true); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), + CancellationToken.None); + + result.ShouldBeOfType().Value + .ShouldBeOfType>() + .Single().Id.ShouldBe(1); + + _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? first).ShouldBeTrue(); + first.ShouldBeOfType().JellyfinMediaSourceId.ShouldBe(1); + _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? second).ShouldBeTrue(); + second.ShouldBeOfType().JellyfinLibraryId.ShouldBe(1); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Skip_Enqueue_When_Library_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + [new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)], + [new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _entityLocker.LockLibrary(1).Returns(false); + + await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), + CancellationToken.None); + + _scannerChannel.Reader.TryRead(out _).ShouldBeFalse(); + } + + [Test] + public async Task GetPathReplacements_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetPathReplacements(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task ReplacePathReplacements_Should_Return_200_With_Reloaded_List() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new JellyfinPathReplacementViewModel(1, "/jellyfin", "/local")]); + + IActionResult result = await _controller.ReplacePathReplacements( + 1, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(1, "/jellyfin", "/local")]), + CancellationToken.None); + + List body = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + body.Single().ShouldBe(new PathReplacementResponseModel(1, "/jellyfin", "/local")); + } + + [Test] + public async Task ReplacePathReplacements_Should_Map_Handler_Error_To_422() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad row"))); + + IActionResult result = await _controller.ReplacePathReplacements( + 1, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(999, "", "")]), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + } + + [Test] + public async Task RefreshLibraries_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.RefreshLibraries(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task RefreshLibraries_Should_Return_409_When_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _entityLocker.IsRemoteMediaSourceLocked().Returns(true); + + IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + } + + [Test] + public async Task RefreshLibraries_Should_Enqueue_And_Return_202() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _entityLocker.IsRemoteMediaSourceLocked().Returns(false); + + IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None); + + result.ShouldBeOfType(); + _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue(); + request.ShouldBeOfType().JellyfinMediaSourceId.ShouldBe(1); + } + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(JellyfinMediaSourcesController).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.Tests/Infrastructure/MediaSourceRepositoryPathReplacementScopeTests.cs b/ErsatzTV.Tests/Infrastructure/MediaSourceRepositoryPathReplacementScopeTests.cs new file mode 100644 index 000000000..a15f13015 --- /dev/null +++ b/ErsatzTV.Tests/Infrastructure/MediaSourceRepositoryPathReplacementScopeTests.cs @@ -0,0 +1,112 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Data.Repositories; +using ErsatzTV.Tests.Support; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Infrastructure; + +// Design #202 findings 2c/8 — repo-level defense-in-depth: the UPDATE for path replacements must be +// scoped to the owning media source so a PUT against source A can never silently overwrite a row +// that belongs to source B, even if a caller bypasses the handler-level ownership guard. +[TestFixture] +public class MediaSourceRepositoryPathReplacementScopeTests +{ + private InMemoryTvContext _db = null!; + private MediaSourceRepository _repository = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _repository = new MediaSourceRepository(_db.Factory, NullLogger.Instance); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task UpdatePathReplacements_Should_Not_Update_A_Row_Owned_By_Another_Jellyfin_Source() + { + var sourceA = new JellyfinMediaSource + { + ServerName = "A", + OperatingSystem = "Linux", + Connections = [], + PathReplacements = [new JellyfinPathReplacement { JellyfinPath = "/a", LocalPath = "/a-local" }] + }; + var sourceB = new JellyfinMediaSource + { + ServerName = "B", + OperatingSystem = "Linux", + Connections = [], + PathReplacements = [new JellyfinPathReplacement { JellyfinPath = "/b", LocalPath = "/b-local" }] + }; + + await using (TvContext context = _db.CreateContext()) + { + context.MediaSources.AddRange(sourceA, sourceB); + await context.SaveChangesAsync(); + } + + int bRowId = sourceB.PathReplacements.Single().Id; + + // attack: source A's PUT carries source B's row id as an "update" + var maliciousUpdate = new JellyfinPathReplacement + { + Id = bRowId, + JellyfinPath = "/hijacked", + LocalPath = "/hijacked-local" + }; + + await _repository.UpdatePathReplacements(sourceA.Id, [], [maliciousUpdate], []); + + await using TvContext verifyContext = _db.CreateContext(); + JellyfinPathReplacement bRowAfter = await verifyContext.JellyfinPathReplacements.FindAsync(bRowId); + bRowAfter.JellyfinPath.ShouldBe("/b"); + bRowAfter.LocalPath.ShouldBe("/b-local"); + } + + [Test] + public async Task UpdatePathReplacements_Should_Not_Update_A_Row_Owned_By_Another_Emby_Source() + { + var sourceA = new EmbyMediaSource + { + ServerName = "A", + OperatingSystem = "Linux", + Connections = [], + PathReplacements = [new EmbyPathReplacement { EmbyPath = "/a", LocalPath = "/a-local" }] + }; + var sourceB = new EmbyMediaSource + { + ServerName = "B", + OperatingSystem = "Linux", + Connections = [], + PathReplacements = [new EmbyPathReplacement { EmbyPath = "/b", LocalPath = "/b-local" }] + }; + + await using (TvContext context = _db.CreateContext()) + { + context.MediaSources.AddRange(sourceA, sourceB); + await context.SaveChangesAsync(); + } + + int bRowId = sourceB.PathReplacements.Single().Id; + + var maliciousUpdate = new EmbyPathReplacement + { + Id = bRowId, + EmbyPath = "/hijacked", + LocalPath = "/hijacked-local" + }; + + await _repository.UpdatePathReplacements(sourceA.Id, [], [maliciousUpdate], []); + + await using TvContext verifyContext = _db.CreateContext(); + EmbyPathReplacement bRowAfter = await verifyContext.EmbyPathReplacements.FindAsync(bRowId); + bRowAfter.EmbyPath.ShouldBe("/b"); + bRowAfter.LocalPath.ShouldBe("/b-local"); + } +} diff --git a/ErsatzTV/Controllers/Api/EmbyMediaSourcesController.cs b/ErsatzTV/Controllers/Api/EmbyMediaSourcesController.cs new file mode 100644 index 000000000..9780e20f4 --- /dev/null +++ b/ErsatzTV/Controllers/Api/EmbyMediaSourcesController.cs @@ -0,0 +1,350 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Emby; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +// Design #202 §A.4 (E1-E9). Copy-symmetric with JellyfinMediaSourcesController. +[ApiController] +public class EmbyMediaSourcesController( + IMediator mediator, + IEntityLocker entityLocker, + ChannelWriter scannerWorkerChannel) : ControllerBase +{ + [HttpGet("/api/media-sources/emby", Name = "GetEmbyState")] + [Tags("Emby")] + [EndpointSummary("Get Emby connection state and discovered servers")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteMediaSourceStateResponseModel), StatusCodes.Status200OK)] + public async Task GetState(CancellationToken cancellationToken) + { + List sources = + await mediator.Send(new GetAllEmbyMediaSources(), cancellationToken); + EmbySecrets secrets = await mediator.Send(new GetEmbySecrets(), cancellationToken); + + bool isAuthorized = !string.IsNullOrWhiteSpace(secrets.Address) && !string.IsNullOrWhiteSpace(secrets.ApiKey); + bool isLocked = entityLocker.IsRemoteMediaSourceLocked(); + + return new RemoteMediaSourceStateResponseModel( + isAuthorized, + isLocked, + sources.Map(s => new RemoteMediaSourceItemResponseModel(s.Id, s.Name, s.Address)).ToList()); + } + + [HttpGet("/api/media-sources/emby/connection", Name = "GetEmbyConnection")] + [Tags("Emby")] + [EndpointSummary("Get the Emby connection address")] + [EndpointDescription( + "Never returns the API key — only whether one is currently configured (design #202 secure connection " + + "contract). Use the PUT to (re)connect; a blank apiKey there retains the existing key.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)] + public async Task GetConnection(CancellationToken cancellationToken) + { + EmbySecrets secrets = await mediator.Send(new GetEmbySecrets(), cancellationToken); + return new RemoteConnectionResponseModel( + secrets.Address ?? string.Empty, + !string.IsNullOrWhiteSpace(secrets.ApiKey)); + } + + [HttpPut("/api/media-sources/emby/connection", Name = "SaveEmbyConnection")] + [Tags("Emby")] + [EndpointSummary("Connect, reconnect, or edit the Emby connection")] + [EndpointDescription( + "A blank/omitted apiKey retains the existing key; a non-blank value sets a new one. The key is required " + + "on first connect (no existing secret).")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task SaveConnection( + [Required] [FromBody] SaveRemoteConnectionRequest request, + CancellationToken cancellationToken) + { + if (entityLocker.IsRemoteMediaSourceLocked()) + { + return ApiResults.ConflictProblem( + "Emby operation in progress", + "An Emby sign-in or sync is already in progress."); + } + + if (!Uri.TryCreate(request.Address, UriKind.Absolute, out _)) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails("Address must be an absolute URI.")); + } + + EmbySecrets existingSecrets = await mediator.Send(new GetEmbySecrets(), cancellationToken); + if (string.IsNullOrWhiteSpace(request.ApiKey) && string.IsNullOrWhiteSpace(existingSecrets.ApiKey)) + { + return new UnprocessableEntityObjectResult(CreateProblemDetails("API key is required.")); + } + + Either result = await mediator.Send( + request.ToEmbyCommand(existingSecrets.ApiKey), + cancellationToken); + + return await result.Match>( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + EmbySecrets saved = await mediator.Send(new GetEmbySecrets(), cancellationToken); + return new OkObjectResult( + new RemoteConnectionResponseModel( + saved.Address ?? string.Empty, + !string.IsNullOrWhiteSpace(saved.ApiKey))); + }); + } + + [HttpPost("/api/media-sources/emby/disconnect", Name = "DisconnectEmby")] + [Tags("Emby")] + [EndpointSummary("Disconnect Emby")] + [EndpointDescription("Purges the Emby connection, discovered servers, and all synced Emby content.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task Disconnect(CancellationToken cancellationToken) + { + if (!entityLocker.LockRemoteMediaSource()) + { + return ApiResults.ConflictProblem( + "Emby operation in progress", + "An Emby sign-in or sync is already in progress."); + } + + Either result = await mediator.Send(new DisconnectEmby(), cancellationToken); + return result.Match( + Left: error => error.ToErrorResult(), + Right: _ => (IActionResult)new NoContentResult()); + } + + [HttpGet("/api/media-sources/emby/{id:int}/libraries", Name = "GetEmbyLibraries")] + [Tags("Emby")] + [EndpointSummary("Get an Emby source's libraries")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetLibraries(int id, CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List libraries = + await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken); + return new OkObjectResult(libraries.Map(ProjectToResponseModel).ToList()); + } + + [HttpPut("/api/media-sources/emby/{id:int}/libraries", Name = "ReplaceEmbyLibraryPreferences")] + [Tags("Emby")] + [EndpointSummary("Replace an Emby source's library sync preferences")] + [EndpointDescription( + "The body must be the complete set of the source's libraries (design #202 §C4a) — a row absent from " + + "the request is rejected, not silently ignored. Ids are not stable across a disable, so re-fetch this " + + "response rather than the request body.")] + [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) + { + Option maybeSource = + await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List existingLibraries = + await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken); + + UnprocessableEntityObjectResult validationError = ValidateLibraryPreferences(request, existingLibraries); + if (validationError is not null) + { + return validationError; + } + + Either result = + await mediator.Send(request.ToEmbyCommand(), cancellationToken); + + return await result.Match>( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + foreach (RemoteLibraryPreferenceRequest library in request.Libraries.Where(l => l.ShouldSyncItems)) + { + await EnqueueLibrarySync(id, library.Id, cancellationToken); + } + + List reloaded = + await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken); + return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList()); + }); + } + + [HttpGet("/api/media-sources/emby/{id:int}/path-replacements", Name = "GetEmbyPathReplacements")] + [Tags("Emby")] + [EndpointSummary("Get an Emby source's path replacements")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetPathReplacements(int id, CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List replacements = + await mediator.Send(new GetEmbyPathReplacementsBySourceId(id), cancellationToken); + return new OkObjectResult(replacements.Map(ProjectToResponseModel).ToList()); + } + + [HttpPut("/api/media-sources/emby/{id:int}/path-replacements", Name = "ReplaceEmbyPathReplacements")] + [Tags("Emby")] + [EndpointSummary("Replace an Emby source's path replacements")] + [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) + { + Option maybeSource = + await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + Either result = + await mediator.Send(request.ToEmbyCommand(id), cancellationToken); + + return await result.Match>( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + List reloaded = + await mediator.Send(new GetEmbyPathReplacementsBySourceId(id), cancellationToken); + return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList()); + }); + } + + [HttpPost("/api/media-sources/emby/{id:int}/refresh-libraries", Name = "RefreshEmbyLibraries")] + [Tags("Emby")] + [EndpointSummary("Refresh an Emby source's libraries")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task RefreshLibraries(int id, CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + if (entityLocker.IsRemoteMediaSourceLocked()) + { + return ApiResults.ConflictProblem( + "Emby operation in progress", + "An Emby sign-in or sync is already in progress."); + } + + await scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(id), cancellationToken); + return new AcceptedResult(); + } + + // §C7: LockLibrary then enqueue the sync pair; a throw from the enqueue compensates by unlocking + // (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity). + private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken) + { + if (!entityLocker.LockLibrary(libraryId)) + { + return; + } + + try + { + await scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(sourceId), cancellationToken); + await scannerWorkerChannel.WriteAsync( + new SynchronizeEmbyLibraryByIdIfNeeded(libraryId), + cancellationToken); + } + catch + { + entityLocker.UnlockLibrary(libraryId); + throw; + } + } + + private static UnprocessableEntityObjectResult ValidateLibraryPreferences( + ReplaceRemoteLibraryPreferencesRequest request, + List existingLibraries) + { + List libraries = request.Libraries ?? []; + + if (libraries.Any(l => l.Id < 1)) + { + return new UnprocessableEntityObjectResult(CreateProblemDetails("Every library id is required.")); + } + + var existingIds = existingLibraries.Map(l => l.Id).ToList(); + var foreignIds = libraries.Filter(l => !existingIds.Contains(l.Id)).Map(l => l.Id).ToList(); + if (foreignIds.Count > 0) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails( + $"Library id(s) {string.Join(", ", foreignIds)} do not belong to this Emby source.")); + } + + var incomingIds = libraries.Map(l => l.Id).ToList(); + var missingIds = existingIds.Filter(existingId => !incomingIds.Contains(existingId)).ToList(); + if (missingIds.Count > 0) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails( + "The request must include every library for this source " + + $"(missing id(s) {string.Join(", ", missingIds)}).")); + } + + return null; + } + + private static ProblemDetails CreateProblemDetails(string detail) => + new() + { + Status = StatusCodes.Status422UnprocessableEntity, + Title = "Validation failed", + Detail = detail + }; + + private static RemoteLibraryResponseModel ProjectToResponseModel(EmbyLibraryViewModel vm) => + new(vm.Id, vm.Name, vm.MediaKind, vm.ShouldSyncItems); + + private static PathReplacementResponseModel ProjectToResponseModel(EmbyPathReplacementViewModel vm) => + new(vm.Id, vm.EmbyPath, vm.LocalPath); +} diff --git a/ErsatzTV/Controllers/Api/JellyfinMediaSourcesController.cs b/ErsatzTV/Controllers/Api/JellyfinMediaSourcesController.cs new file mode 100644 index 000000000..aa767ed14 --- /dev/null +++ b/ErsatzTV/Controllers/Api/JellyfinMediaSourcesController.cs @@ -0,0 +1,350 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Jellyfin; +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.Jellyfin; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +// Design #202 §A.3 (J1-J9). Jellyfin and Emby are copy-symmetric — see EmbyMediaSourcesController. +[ApiController] +public class JellyfinMediaSourcesController( + IMediator mediator, + IEntityLocker entityLocker, + ChannelWriter scannerWorkerChannel) : ControllerBase +{ + [HttpGet("/api/media-sources/jellyfin", Name = "GetJellyfinState")] + [Tags("Jellyfin")] + [EndpointSummary("Get Jellyfin connection state and discovered servers")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteMediaSourceStateResponseModel), StatusCodes.Status200OK)] + public async Task GetState(CancellationToken cancellationToken) + { + List sources = + await mediator.Send(new GetAllJellyfinMediaSources(), cancellationToken); + JellyfinSecrets secrets = await mediator.Send(new GetJellyfinSecrets(), cancellationToken); + + bool isAuthorized = !string.IsNullOrWhiteSpace(secrets.Address) && !string.IsNullOrWhiteSpace(secrets.ApiKey); + bool isLocked = entityLocker.IsRemoteMediaSourceLocked(); + + return new RemoteMediaSourceStateResponseModel( + isAuthorized, + isLocked, + sources.Map(s => new RemoteMediaSourceItemResponseModel(s.Id, s.Name, s.Address)).ToList()); + } + + [HttpGet("/api/media-sources/jellyfin/connection", Name = "GetJellyfinConnection")] + [Tags("Jellyfin")] + [EndpointSummary("Get the Jellyfin connection address")] + [EndpointDescription( + "Never returns the API key — only whether one is currently configured (design #202 secure connection " + + "contract). Use the PUT to (re)connect; a blank apiKey there retains the existing key.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)] + public async Task GetConnection(CancellationToken cancellationToken) + { + JellyfinSecrets secrets = await mediator.Send(new GetJellyfinSecrets(), cancellationToken); + return new RemoteConnectionResponseModel( + secrets.Address ?? string.Empty, + !string.IsNullOrWhiteSpace(secrets.ApiKey)); + } + + [HttpPut("/api/media-sources/jellyfin/connection", Name = "SaveJellyfinConnection")] + [Tags("Jellyfin")] + [EndpointSummary("Connect, reconnect, or edit the Jellyfin connection")] + [EndpointDescription( + "A blank/omitted apiKey retains the existing key; a non-blank value sets a new one. The key is required " + + "on first connect (no existing secret).")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task SaveConnection( + [Required] [FromBody] SaveRemoteConnectionRequest request, + CancellationToken cancellationToken) + { + if (entityLocker.IsRemoteMediaSourceLocked()) + { + return ApiResults.ConflictProblem( + "Jellyfin operation in progress", + "A Jellyfin sign-in or sync is already in progress."); + } + + if (!Uri.TryCreate(request.Address, UriKind.Absolute, out _)) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails("Address must be an absolute URI.")); + } + + JellyfinSecrets existingSecrets = await mediator.Send(new GetJellyfinSecrets(), cancellationToken); + if (string.IsNullOrWhiteSpace(request.ApiKey) && string.IsNullOrWhiteSpace(existingSecrets.ApiKey)) + { + return new UnprocessableEntityObjectResult(CreateProblemDetails("API key is required.")); + } + + Either result = await mediator.Send( + request.ToJellyfinCommand(existingSecrets.ApiKey), + cancellationToken); + + return await result.Match>( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + JellyfinSecrets saved = await mediator.Send(new GetJellyfinSecrets(), cancellationToken); + return new OkObjectResult( + new RemoteConnectionResponseModel( + saved.Address ?? string.Empty, + !string.IsNullOrWhiteSpace(saved.ApiKey))); + }); + } + + [HttpPost("/api/media-sources/jellyfin/disconnect", Name = "DisconnectJellyfin")] + [Tags("Jellyfin")] + [EndpointSummary("Disconnect Jellyfin")] + [EndpointDescription("Purges the Jellyfin connection, discovered servers, and all synced Jellyfin content.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task Disconnect(CancellationToken cancellationToken) + { + if (!entityLocker.LockRemoteMediaSource()) + { + return ApiResults.ConflictProblem( + "Jellyfin operation in progress", + "A Jellyfin sign-in or sync is already in progress."); + } + + Either result = await mediator.Send(new DisconnectJellyfin(), cancellationToken); + return result.Match( + Left: error => error.ToErrorResult(), + Right: _ => (IActionResult)new NoContentResult()); + } + + [HttpGet("/api/media-sources/jellyfin/{id:int}/libraries", Name = "GetJellyfinLibraries")] + [Tags("Jellyfin")] + [EndpointSummary("Get a Jellyfin source's libraries")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetLibraries(int id, CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List libraries = + await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken); + return new OkObjectResult(libraries.Map(ProjectToResponseModel).ToList()); + } + + [HttpPut("/api/media-sources/jellyfin/{id:int}/libraries", Name = "ReplaceJellyfinLibraryPreferences")] + [Tags("Jellyfin")] + [EndpointSummary("Replace a Jellyfin source's library sync preferences")] + [EndpointDescription( + "The body must be the complete set of the source's libraries (design #202 §C4a) — a row absent from " + + "the request is rejected, not silently ignored. Ids are not stable across a disable, so re-fetch this " + + "response rather than the request body.")] + [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) + { + Option maybeSource = + await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List existingLibraries = + await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken); + + UnprocessableEntityObjectResult validationError = ValidateLibraryPreferences(request, existingLibraries); + if (validationError is not null) + { + return validationError; + } + + Either result = + await mediator.Send(request.ToJellyfinCommand(), cancellationToken); + + return await result.Match>( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + foreach (RemoteLibraryPreferenceRequest library in request.Libraries.Where(l => l.ShouldSyncItems)) + { + await EnqueueLibrarySync(id, library.Id, cancellationToken); + } + + List reloaded = + await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken); + return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList()); + }); + } + + [HttpGet("/api/media-sources/jellyfin/{id:int}/path-replacements", Name = "GetJellyfinPathReplacements")] + [Tags("Jellyfin")] + [EndpointSummary("Get a Jellyfin source's path replacements")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetPathReplacements(int id, CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List replacements = + await mediator.Send(new GetJellyfinPathReplacementsBySourceId(id), cancellationToken); + return new OkObjectResult(replacements.Map(ProjectToResponseModel).ToList()); + } + + [HttpPut("/api/media-sources/jellyfin/{id:int}/path-replacements", Name = "ReplaceJellyfinPathReplacements")] + [Tags("Jellyfin")] + [EndpointSummary("Replace a Jellyfin source's path replacements")] + [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) + { + Option maybeSource = + await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + Either result = + await mediator.Send(request.ToJellyfinCommand(id), cancellationToken); + + return await result.Match>( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + List reloaded = + await mediator.Send(new GetJellyfinPathReplacementsBySourceId(id), cancellationToken); + return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList()); + }); + } + + [HttpPost("/api/media-sources/jellyfin/{id:int}/refresh-libraries", Name = "RefreshJellyfinLibraries")] + [Tags("Jellyfin")] + [EndpointSummary("Refresh a Jellyfin source's libraries")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task RefreshLibraries(int id, CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + if (entityLocker.IsRemoteMediaSourceLocked()) + { + return ApiResults.ConflictProblem( + "Jellyfin operation in progress", + "A Jellyfin sign-in or sync is already in progress."); + } + + await scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(id), cancellationToken); + return new AcceptedResult(); + } + + // §C7: LockLibrary then enqueue the sync pair; a throw from the enqueue compensates by unlocking + // (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity). + private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken) + { + if (!entityLocker.LockLibrary(libraryId)) + { + return; + } + + try + { + await scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(sourceId), cancellationToken); + await scannerWorkerChannel.WriteAsync( + new SynchronizeJellyfinLibraryByIdIfNeeded(libraryId), + cancellationToken); + } + catch + { + entityLocker.UnlockLibrary(libraryId); + throw; + } + } + + private static UnprocessableEntityObjectResult ValidateLibraryPreferences( + ReplaceRemoteLibraryPreferencesRequest request, + List existingLibraries) + { + List libraries = request.Libraries ?? []; + + if (libraries.Any(l => l.Id < 1)) + { + return new UnprocessableEntityObjectResult(CreateProblemDetails("Every library id is required.")); + } + + var existingIds = existingLibraries.Map(l => l.Id).ToList(); + var foreignIds = libraries.Filter(l => !existingIds.Contains(l.Id)).Map(l => l.Id).ToList(); + if (foreignIds.Count > 0) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails( + $"Library id(s) {string.Join(", ", foreignIds)} do not belong to this Jellyfin source.")); + } + + var incomingIds = libraries.Map(l => l.Id).ToList(); + var missingIds = existingIds.Filter(existingId => !incomingIds.Contains(existingId)).ToList(); + if (missingIds.Count > 0) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails( + "The request must include every library for this source " + + $"(missing id(s) {string.Join(", ", missingIds)}).")); + } + + return null; + } + + private static ProblemDetails CreateProblemDetails(string detail) => + new() + { + Status = StatusCodes.Status422UnprocessableEntity, + Title = "Validation failed", + Detail = detail + }; + + private static RemoteLibraryResponseModel ProjectToResponseModel(JellyfinLibraryViewModel vm) => + new(vm.Id, vm.Name, vm.MediaKind, vm.ShouldSyncItems); + + private static PathReplacementResponseModel ProjectToResponseModel(JellyfinPathReplacementViewModel vm) => + new(vm.Id, vm.JellyfinPath, vm.LocalPath); +}