From 4306b39ef81772455404bcd1cf561a8085d6b167 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 15:09:12 +0200 Subject: [PATCH 1/5] feat(api): Trakt lists REST endpoints (#160) Adds TraktController (GET/POST /api/trakt/lists, GET/PUT/DELETE by id, POST .../match, GET /api/trakt/status) covering the API gap for SPA parity issue #142. Add/match/delete dispatch to the same ChannelWriter the classic Blazor "Trakt Lists" page uses, gated by the existing IEntityLocker (LockTrakt/ IsTraktLocked) singleton; GET /api/trakt/status wraps IsTraktLocked as the HTTP-observable substitute for the Blazor page's OnTraktChanged event. URL validation mirrors AddTraktListHandler.ValidateUrl's regexes (replicated, since that method is private to the handler and returns a handler-private record) so an obviously-invalid URL gets a synchronous 422 before dispatch. Adds TraktListResponseModel/PagedTraktListsResponseModel/ TraktStatusResponseModel DTOs, AddTraktListRequest/UpdateTraktListRequest, controller tests, ApiControllerSecurityTests + OpenApiErrorResponseContractTests coverage, and regenerates the checked-in OpenAPI document. --- .../Api/Trakt/PagedTraktListsResponseModel.cs | 4 + .../Api/Trakt/TraktListResponseModel.cs | 12 + .../Api/Trakt/TraktStatusResponseModel.cs | 8 + .../Controllers/ApiControllerSecurityTests.cs | 1 + .../OpenApiErrorResponseContractTests.cs | 9 + .../Controllers/TraktControllerTests.cs | 267 +++++++++ .../Api/Requests/AddTraktListRequest.cs | 3 + .../Api/Requests/UpdateTraktListRequest.cs | 3 + ErsatzTV/Controllers/Api/TraktController.cs | 242 ++++++++ ErsatzTV/wwwroot/openapi/v1.json | 557 ++++++++++++++++++ 10 files changed, 1106 insertions(+) create mode 100644 ErsatzTV.Core/Api/Trakt/PagedTraktListsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Trakt/TraktListResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Trakt/TraktStatusResponseModel.cs create mode 100644 ErsatzTV.Tests/Controllers/TraktControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/AddTraktListRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateTraktListRequest.cs create mode 100644 ErsatzTV/Controllers/Api/TraktController.cs diff --git a/ErsatzTV.Core/Api/Trakt/PagedTraktListsResponseModel.cs b/ErsatzTV.Core/Api/Trakt/PagedTraktListsResponseModel.cs new file mode 100644 index 000000000..ebe9b5aa7 --- /dev/null +++ b/ErsatzTV.Core/Api/Trakt/PagedTraktListsResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Trakt; + +public record PagedTraktListsResponseModel(int TotalCount, List Page); diff --git a/ErsatzTV.Core/Api/Trakt/TraktListResponseModel.cs b/ErsatzTV.Core/Api/Trakt/TraktListResponseModel.cs new file mode 100644 index 000000000..f1db6a962 --- /dev/null +++ b/ErsatzTV.Core/Api/Trakt/TraktListResponseModel.cs @@ -0,0 +1,12 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Trakt; + +public record TraktListResponseModel( + int Id, + int TraktId, + string Slug, + string Name, + int ItemCount, + int MatchCount, + bool AutoRefresh, + bool GeneratePlaylist); diff --git a/ErsatzTV.Core/Api/Trakt/TraktStatusResponseModel.cs b/ErsatzTV.Core/Api/Trakt/TraktStatusResponseModel.cs new file mode 100644 index 000000000..8543dddd5 --- /dev/null +++ b/ErsatzTV.Core/Api/Trakt/TraktStatusResponseModel.cs @@ -0,0 +1,8 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Trakt; + +/// +/// HTTP-observable substitute for the Blazor IEntityLocker.OnTraktChanged event — the SPA polls this +/// while an add/match/delete operation is in flight (no push channel exists for the REST API). +/// +public record TraktStatusResponseModel(bool Busy); diff --git a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs index 4acfe3ca8..7e5a44335 100644 --- a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs @@ -39,6 +39,7 @@ public class ApiControllerSecurityTests typeof(SessionController), typeof(SettingsController), typeof(SmartCollectionController), + typeof(TraktController), typeof(TroubleshootController) ]; diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 39c6f80f7..5ec9e40f7 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -193,6 +193,15 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/settings/resolutions/{id}", "delete", "401")] [TestCase("/api/settings/resolutions/{id}", "delete", "404")] [TestCase("/api/settings/resolutions/{id}", "delete", "422")] + [TestCase("/api/trakt/lists/{id}", "get", "404")] + [TestCase("/api/trakt/lists", "post", "422")] + [TestCase("/api/trakt/lists", "post", "409")] + [TestCase("/api/trakt/lists/{id}/match", "post", "404")] + [TestCase("/api/trakt/lists/{id}/match", "post", "409")] + [TestCase("/api/trakt/lists/{id}", "delete", "404")] + [TestCase("/api/trakt/lists/{id}", "delete", "409")] + [TestCase("/api/trakt/lists/{id}", "put", "404")] + [TestCase("/api/trakt/lists/{id}", "put", "422")] public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses( string path, string method, diff --git a/ErsatzTV.Tests/Controllers/TraktControllerTests.cs b/ErsatzTV.Tests/Controllers/TraktControllerTests.cs new file mode 100644 index 000000000..d8ab2aa81 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/TraktControllerTests.cs @@ -0,0 +1,267 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Trakt; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Locking; +using LanguageExt; +using static LanguageExt.Prelude; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class TraktControllerTests +{ + private IMediator _mediator = null!; + private IEntityLocker _entityLocker = null!; + private Channel _workerChannel = null!; + private TraktController _controller = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _entityLocker = Substitute.For(); + _workerChannel = Channel.CreateUnbounded(); + _controller = new TraktController(_mediator, _workerChannel.Writer, _entityLocker); + } + + [Test] + public async Task GetAll_Should_Clamp_Paging_And_Project_Response() + { + var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, true, false); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedTraktListsViewModel(1, [vm])); + + PagedTraktListsResponseModel result = await _controller.GetAll(-5, 500, CancellationToken.None); + + result.TotalCount.ShouldBe(1); + result.Page.ShouldBe( + [new TraktListResponseModel(1, 100, "my-list", "My List", 10, 8, true, false)]); + await _mediator.Received(1).Send( + Arg.Is(q => q.PageNum == 0 && q.PageSize == 100), + Arg.Any()); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, true, false); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.GetById(1, CancellationToken.None); + + result.ShouldBeOfType().Value + .ShouldBe(new TraktListResponseModel(1, 100, "my-list", "My List", 10, 8, true, false)); + } + + [Test] + public async Task GetById_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(99, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problemDetails = notFound.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(404); + } + + [Test] + public async Task Add_Should_Return_422_For_Invalid_Url() + { + IActionResult result = await _controller.Add( + new AddTraktListRequest("not-a-trakt-url ?? "), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + _entityLocker.DidNotReceive().LockTrakt(); + } + + [TestCase("https://trakt.tv/users/someuser/lists/some-list")] + [TestCase("https://app.trakt.tv/users/someuser/some-list")] + [TestCase("https://trakt.tv/lists/someuser/some-list")] + [TestCase("someuser/lists/some-list")] + [TestCase("someuser/some-list")] + public async Task Add_Should_Accept_Known_Trakt_Url_Shapes_And_Dispatch(string url) + { + _entityLocker.LockTrakt().Returns(true); + + IActionResult result = await _controller.Add(new AddTraktListRequest(url), CancellationToken.None); + + result.ShouldBeOfType(); + _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); + request.ShouldBeOfType().TraktListUrl.ShouldBe(url); + } + + [Test] + public async Task Add_Should_Return_409_When_Already_Locked() + { + _entityLocker.LockTrakt().Returns(false); + + IActionResult result = await _controller.Add( + new AddTraktListRequest("https://trakt.tv/users/someuser/lists/some-list"), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + _workerChannel.Reader.TryRead(out _).ShouldBeFalse(); + } + + [Test] + public async Task Match_Should_Return_404_When_List_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Match(99, CancellationToken.None); + + result.ShouldBeOfType(); + _entityLocker.DidNotReceive().LockTrakt(); + } + + [Test] + public async Task Match_Should_Dispatch_And_Return_202() + { + var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, true, false); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + _entityLocker.LockTrakt().Returns(true); + + IActionResult result = await _controller.Match(1, CancellationToken.None); + + result.ShouldBeOfType(); + _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); + request.ShouldBeOfType().TraktListId.ShouldBe(1); + } + + [Test] + public async Task Match_Should_Return_409_When_Already_Locked() + { + var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, true, false); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + _entityLocker.LockTrakt().Returns(false); + + IActionResult result = await _controller.Match(1, CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + } + + [Test] + public async Task Delete_Should_Return_404_When_List_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Delete(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Dispatch_And_Return_202() + { + var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, true, false); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + _entityLocker.LockTrakt().Returns(true); + + IActionResult result = await _controller.Delete(1, CancellationToken.None); + + result.ShouldBeOfType(); + _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); + request.ShouldBeOfType().TraktListId.ShouldBe(1); + } + + [Test] + public async Task Update_Should_Return_404_When_List_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Update( + 99, + new UpdateTraktListRequest(true, false), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_200_With_Updated_List() + { + var before = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, false, false); + var after = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, true, true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(before), Option.Some(after)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Update( + 1, + new UpdateTraktListRequest(true, true), + CancellationToken.None); + + result.ShouldBeOfType().Value + .ShouldBe(new TraktListResponseModel(1, 100, "my-list", "My List", 10, 8, true, true)); + await _mediator.Received(1).Send( + Arg.Is(c => c.Id == 1 && c.AutoRefresh && c.GeneratePlaylist), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_422_For_Error() + { + var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, false, false); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(BaseError.New("bad"))); + + IActionResult result = await _controller.Update( + 1, + new UpdateTraktListRequest(true, true), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Return_404_For_NotFoundError() + { + var vm = new TraktListViewModel(1, 100, "my-list", "My List", 10, 8, false, false); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new NotFoundError("missing"))); + + IActionResult result = await _controller.Update( + 1, + new UpdateTraktListRequest(true, true), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [TestCase(true)] + [TestCase(false)] + public void GetStatus_Should_Wrap_IsTraktLocked(bool busy) + { + _entityLocker.IsTraktLocked().Returns(busy); + + TraktStatusResponseModel result = _controller.GetStatus(); + + result.Busy.ShouldBe(busy); + } +} diff --git a/ErsatzTV/Controllers/Api/Requests/AddTraktListRequest.cs b/ErsatzTV/Controllers/Api/Requests/AddTraktListRequest.cs new file mode 100644 index 000000000..8cf4feb63 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/AddTraktListRequest.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Controllers.Api.Requests; + +public record AddTraktListRequest(string Url); diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateTraktListRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateTraktListRequest.cs new file mode 100644 index 000000000..88f0b134b --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateTraktListRequest.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateTraktListRequest(bool AutoRefresh, bool GeneratePlaylist); diff --git a/ErsatzTV/Controllers/Api/TraktController.cs b/ErsatzTV/Controllers/Api/TraktController.cs new file mode 100644 index 000000000..bd614afdb --- /dev/null +++ b/ErsatzTV/Controllers/Api/TraktController.cs @@ -0,0 +1,242 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Trakt; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public partial class TraktController( + IMediator mediator, + ChannelWriter workerChannel, + IEntityLocker entityLocker) : ControllerBase +{ + private const int MaxPageSize = 100; + + [HttpGet("/api/trakt/lists", Name = "GetTraktLists")] + [Tags("Trakt")] + [EndpointSummary("Get paged Trakt lists")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PagedTraktListsResponseModel), StatusCodes.Status200OK)] + public async Task GetAll( + [FromQuery] int pageNum = 0, + [FromQuery] int pageSize = 100, + CancellationToken cancellationToken = default) + { + int clampedPageNum = Math.Max(0, pageNum); + int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize); + + PagedTraktListsViewModel result = await mediator.Send( + new GetPagedTraktLists(clampedPageNum, clampedPageSize), + cancellationToken); + + return new PagedTraktListsResponseModel( + result.TotalCount, + result.Page.Map(ProjectToResponseModel).ToList()); + } + + [HttpGet("/api/trakt/lists/{id:int}", Name = "GetTraktListById")] + [Tags("Trakt")] + [EndpointSummary("Get a Trakt list by id")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(TraktListResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetTraktListById(id), cancellationToken); + return result.Map(ProjectToResponseModel).ToGetResult(); + } + + [HttpPost("/api/trakt/lists")] + [Tags("Trakt")] + [EndpointSummary("Add a Trakt list by URL")] + [EndpointDescription( + "Dispatches to the same background worker channel used by the classic UI's \"Add Trakt List\" dialog; " + + "the list is fetched, saved, and matched asynchronously. Poll GET /api/trakt/status while busy.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task Add( + [Required] [FromBody] AddTraktListRequest request, + CancellationToken cancellationToken) + { + if (!IsValidTraktListUrl(request.Url)) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails(422, "Validation failed", "Invalid Trakt list url")); + } + + if (!entityLocker.LockTrakt()) + { + return ConflictProblem(); + } + + await workerChannel.WriteAsync(AddTraktList.FromUrl(request.Url), cancellationToken); + return new AcceptedResult(); + } + + [HttpPost("/api/trakt/lists/{id:int}/match")] + [Tags("Trakt")] + [EndpointSummary("Match a Trakt list's items")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task Match(int id, CancellationToken cancellationToken) + { + Option existing = await mediator.Send(new GetTraktListById(id), cancellationToken); + if (existing.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + if (!entityLocker.LockTrakt()) + { + return ConflictProblem(); + } + + await workerChannel.WriteAsync(new MatchTraktListItems(id), cancellationToken); + return new AcceptedResult(); + } + + [HttpDelete("/api/trakt/lists/{id:int}")] + [Tags("Trakt")] + [EndpointSummary("Delete a Trakt list")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Option existing = await mediator.Send(new GetTraktListById(id), cancellationToken); + if (existing.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + if (!entityLocker.LockTrakt()) + { + return ConflictProblem(); + } + + await workerChannel.WriteAsync(new DeleteTraktList(id), cancellationToken); + return new AcceptedResult(); + } + + [HttpPut("/api/trakt/lists/{id:int}")] + [Tags("Trakt")] + [EndpointSummary("Update a Trakt list's settings")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(TraktListResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Update( + int id, + [Required] [FromBody] UpdateTraktListRequest request, + CancellationToken cancellationToken) + { + Option existing = await mediator.Send(new GetTraktListById(id), cancellationToken); + if (existing.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + Option maybeError = await mediator.Send( + new UpdateTraktList(id, request.AutoRefresh, request.GeneratePlaylist), + cancellationToken); + + foreach (BaseError error in maybeError) + { + return error.ToErrorResult(); + } + + Option updated = await mediator.Send(new GetTraktListById(id), cancellationToken); + return updated.Map(ProjectToResponseModel).ToGetResult(); + } + + [HttpGet("/api/trakt/status", Name = "GetTraktStatus")] + [Tags("Trakt")] + [EndpointSummary("Get Trakt background operation status")] + [EndpointDescription( + "Wraps IEntityLocker.IsTraktLocked() — the HTTP-observable substitute for the Blazor page's live lock " + + "event. The SPA polls this while add/match/delete are in flight.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(TraktStatusResponseModel), StatusCodes.Status200OK)] + public TraktStatusResponseModel GetStatus() => new(entityLocker.IsTraktLocked()); + + private static TraktListResponseModel ProjectToResponseModel(TraktListViewModel viewModel) => + new( + viewModel.Id, + viewModel.TraktId, + viewModel.Slug, + viewModel.Name, + viewModel.ItemCount, + viewModel.MatchCount, + viewModel.AutoRefresh, + viewModel.GeneratePlaylist); + + private static ConflictObjectResult ConflictProblem() => + new ConflictObjectResult( + CreateProblemDetails( + 409, + "Trakt operation in progress", + "A Trakt background operation is already in progress")); + + private static ProblemDetails CreateProblemDetails(int status, string title, string detail) => + new() + { + Status = status, + Title = title, + Detail = detail + }; + + // The following mirrors AddTraktListHandler.ValidateUrl (ErsatzTV.Application/MediaCollections/Commands/ + // AddTraktListHandler.cs). That method is private to the handler, operates on the handler's own request type, + // and returns a handler-private record, so it can't be called from here directly — replicated minimally so the + // controller can reject an obviously-invalid URL with a synchronous 422 before dispatching to the background + // worker (which otherwise would silently no-op on a bad URL, since AddTraktListHandler's own ValidateUrl runs + // fire-and-forget on the worker channel). + [GeneratedRegex(@"https:\/\/(?:app\.)?trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")] + private static partial Regex UriTraktListRegex(); + + [GeneratedRegex(@"https:\/\/(?:app\.)?trakt\.tv\/lists\/([\w\-_]+)\/([\w\-_]+)")] + private static partial Regex UriTraktListRegex2(); + + [GeneratedRegex(@"([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")] + private static partial Regex ShorthandTraktListRegex(); + + private static bool IsValidTraktListUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + Match match = Uri.IsWellFormedUriString(url, UriKind.Absolute) + ? MatchTraktListUrl(url) + : ShorthandTraktListRegex().Match(url); + + return match.Success; + } + + private static Match MatchTraktListUrl(string url) + { + Match match = UriTraktListRegex().Match(url); + if (!match.Success) + { + match = UriTraktListRegex2().Match(url); + } + + return match; + } +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index dea50d788..e8af54b0f 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -5491,6 +5491,458 @@ } } }, + "/api/trakt/lists": { + "get": { + "tags": [ + "Trakt" + ], + "summary": "Get paged Trakt lists", + "operationId": "GetTraktLists", + "parameters": [ + { + "name": "pageNum", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PagedTraktListsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedTraktListsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PagedTraktListsResponseModel" + } + } + } + } + } + }, + "post": { + "tags": [ + "Trakt" + ], + "summary": "Add a Trakt list by URL", + "description": "Dispatches to the same background worker channel used by the classic UI's \"Add Trakt List\" dialog; the list is fetched, saved, and matched asynchronously. Poll GET /api/trakt/status while busy.", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/AddTraktListRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddTraktListRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/AddTraktListRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/AddTraktListRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted" + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/trakt/lists/{id}": { + "get": { + "tags": [ + "Trakt" + ], + "summary": "Get a Trakt list by id", + "operationId": "GetTraktListById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/TraktListResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TraktListResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/TraktListResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Trakt" + ], + "summary": "Delete a Trakt list", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "put": { + "tags": [ + "Trakt" + ], + "summary": "Update a Trakt list's settings", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateTraktListRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTraktListRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTraktListRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateTraktListRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/TraktListResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TraktListResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/TraktListResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/trakt/lists/{id}/match": { + "post": { + "tags": [ + "Trakt" + ], + "summary": "Match a Trakt list's items", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/trakt/status": { + "get": { + "tags": [ + "Trakt" + ], + "summary": "Get Trakt background operation status", + "description": "Wraps IEntityLocker.IsTraktLocked() — the HTTP-observable substitute for the Blazor page's live lock event. The SPA polls this while add/match/delete are in flight.", + "operationId": "GetTraktStatus", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/TraktStatusResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TraktStatusResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/TraktStatusResponseModel" + } + } + } + } + } + } + }, "/api/troubleshoot/info": { "get": { "tags": [ @@ -5979,6 +6431,20 @@ } } }, + "AddTraktListRequest": { + "required": [ + "url" + ], + "type": "object", + "properties": { + "url": { + "type": [ + "null", + "string" + ] + } + } + }, "ArtworkContentTypeModel": { "required": [ "path", @@ -8810,6 +9276,25 @@ } } }, + "PagedTraktListsResponseModel": { + "required": [ + "totalCount", + "page" + ], + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TraktListResponseModel" + } + } + } + }, "PlaybackOrder": { "enum": [ "None", @@ -9851,6 +10336,60 @@ ], "type": "string" }, + "TraktListResponseModel": { + "required": [ + "id", + "traktId", + "slug", + "name", + "itemCount", + "matchCount", + "autoRefresh", + "generatePlaylist" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "traktId": { + "type": "integer", + "format": "int32" + }, + "slug": { + "type": "string" + }, + "name": { + "type": "string" + }, + "itemCount": { + "type": "integer", + "format": "int32" + }, + "matchCount": { + "type": "integer", + "format": "int32" + }, + "autoRefresh": { + "type": "boolean" + }, + "generatePlaylist": { + "type": "boolean" + } + } + }, + "TraktStatusResponseModel": { + "required": [ + "busy" + ], + "type": "object", + "properties": { + "busy": { + "type": "boolean" + } + } + }, "TroubleshootingInfoResponseModel": { "required": [ "generalJson", @@ -10612,6 +11151,21 @@ } } }, + "UpdateTraktListRequest": { + "required": [ + "autoRefresh", + "generatePlaylist" + ], + "type": "object", + "properties": { + "autoRefresh": { + "type": "boolean" + }, + "generatePlaylist": { + "type": "boolean" + } + } + }, "UpdateUiSettingsRequest": { "required": [ "isDarkMode", @@ -10864,6 +11418,9 @@ { "name": "Smart Collections" }, + { + "name": "Trakt" + }, { "name": "Troubleshooting" }, From 5c932fdc546e9b25e69742f5cd86e1ec2b551013 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 15:09:30 +0200 Subject: [PATCH 2/5] feat(web): Trakt Lists screen for SPA parity (#142) Adds web/src/api/trakt.ts wrapping the new /api/trakt/* endpoints (#160) plus URL-assert tests, and web/src/screens/TraktListsScreen.tsx: a table (slug, name, match count) with add-by-URL, match/refresh, delete, and a sub-path editor at /app/trakt-lists/{id} (slug read-only, autoRefresh/generatePlaylist toggles, save via PUT) - same allowSubPaths pattern as ChannelEditScreen/SettingsScreen. Since add/match/delete are async background jobs, the screen polls GET /api/trakt/status while busy (disabling actions) and refreshes the list on the busy -> idle transition. "View matched items" links out to the classic Blazor search page (/search?query=trakt_list:{traktId}) since the SPA has no search screen yet (#161) - same interim deep-link pattern used elsewhere. Registered in App.tsx's Media nav group; SettingsScreen's Classic-UI help text no longer lists trakt now that it's SPA-native. --- web/src/App.tsx | 23 +- web/src/api/generated/v1.d.ts | 24 ++ web/src/api/index.ts | 1 + web/src/api/trakt.test.ts | 129 +++++++ web/src/api/trakt.ts | 70 ++++ web/src/screens/SettingsScreen.tsx | 2 +- web/src/screens/TraktListsScreen.tsx | 524 +++++++++++++++++++++++++++ 7 files changed, 771 insertions(+), 2 deletions(-) create mode 100644 web/src/api/trakt.test.ts create mode 100644 web/src/api/trakt.ts create mode 100644 web/src/screens/TraktListsScreen.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 7cc61a3ce..7a6df290b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import { LayoutDashboard, LayoutGrid, Library, + Link2, ListVideo, MonitorPlay, Music, @@ -59,6 +60,7 @@ import { ChannelEditScreen } from './screens/ChannelEditScreen'; import { CollectionsScreen } from './screens/CollectionsScreen'; import { LogsScreen } from './screens/LogsScreen'; import { SettingsScreen } from './screens/SettingsScreen'; +import { TraktListsScreen } from './screens/TraktListsScreen'; import { TroubleshootingScreen } from './screens/TroubleshootingScreen'; import { navigateToPath } from './routing'; import { @@ -135,6 +137,7 @@ type ScreenId = | 'playouts' | 'collections' | 'libraries' + | 'traktLists' | 'settings' | 'logs' | 'troubleshooting'; @@ -260,6 +263,20 @@ const routes: ScreenRoute[] = [ primaryAction: 'Scan', placeholder: 'Libraries workspace' }, + { + // The editor lives at a sub-path (/app/trakt-lists/{id}); the screen owns parsing the + // {id} suffix itself (see TraktListsScreen), same pattern as editChannel/settings. + id: 'traktLists', + path: '/app/trakt-lists', + label: 'Trakt Lists', + title: 'Trakt Lists', + kicker: 'Media', + description: 'Add, match, and manage Trakt list imports.', + icon: