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 5f93cc9c4..911befbe2 100644 --- a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs @@ -41,6 +41,7 @@ public class ApiControllerSecurityTests typeof(SessionController), typeof(SettingsController), typeof(SmartCollectionController), + typeof(TraktController), typeof(TroubleshootController), typeof(WatermarkController) ]; diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 438cd39a4..22c14c643 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -215,6 +215,15 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/settings/resolutions/{id}", "delete", "422")] [TestCase("/api/search", "get", "422")] [TestCase("/api/media-items", "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..ae919a4d1 --- /dev/null +++ b/ErsatzTV/Controllers/Api/TraktController.cs @@ -0,0 +1,248 @@ +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")); + } + + return await EnqueueWithTraktLock(AddTraktList.FromUrl(request.Url), cancellationToken); + } + + [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(); + } + + return await EnqueueWithTraktLock(new MatchTraktListItems(id), cancellationToken); + } + + [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(); + } + + return await EnqueueWithTraktLock(new DeleteTraktList(id), cancellationToken); + } + + [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 async Task EnqueueWithTraktLock( + IBackgroundServiceRequest request, + CancellationToken cancellationToken) + { + if (!entityLocker.LockTrakt()) + { + return ConflictProblem(); + } + + try + { + await workerChannel.WriteAsync(request, cancellationToken); + } + catch + { + // the background handler only unlocks when it receives the message; + // if enqueueing fails (e.g. request aborted), release the lock here or it is held forever + entityLocker.UnlockTrakt(); + throw; + } + + return new AcceptedResult(); + } + + 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 98240251b..8170cd9ba 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -6045,6 +6045,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": [ @@ -6917,6 +7369,20 @@ } } }, + "AddTraktListRequest": { + "required": [ + "url" + ], + "type": "object", + "properties": { + "url": { + "type": [ + "null", + "string" + ] + } + } + }, "ArtworkContentTypeModel": { "required": [ "path", @@ -10082,6 +10548,25 @@ } } }, + "PagedTraktListsResponseModel": { + "required": [ + "totalCount", + "page" + ], + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TraktListResponseModel" + } + } + } + }, "PlaybackOrder": { "enum": [ "None", @@ -11189,6 +11674,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", @@ -12052,6 +12591,21 @@ } } }, + "UpdateTraktListRequest": { + "required": [ + "autoRefresh", + "generatePlaylist" + ], + "type": "object", + "properties": { + "autoRefresh": { + "type": "boolean" + }, + "generatePlaylist": { + "type": "boolean" + } + } + }, "UpdateUiSettingsRequest": { "required": [ "isDarkMode", @@ -12504,6 +13058,9 @@ { "name": "Smart Collections" }, + { + "name": "Trakt" + }, { "name": "Troubleshooting" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index b5a18844e..8ebfb3dd0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import { LayoutDashboard, LayoutGrid, Library, + Link2, ListVideo, MonitorPlay, Music, @@ -65,6 +66,7 @@ import { LogsScreen } from './screens/LogsScreen'; import { MediaBrowseScreen } from './screens/MediaBrowseScreen'; import { SearchScreen } from './screens/SearchScreen'; import { SettingsScreen } from './screens/SettingsScreen'; +import { TraktListsScreen } from './screens/TraktListsScreen'; import { TrashScreen } from './screens/TrashScreen'; import { TroubleshootingScreen } from './screens/TroubleshootingScreen'; import { WatermarksScreen } from './screens/WatermarksScreen'; @@ -147,6 +149,7 @@ type ScreenId = | 'collections' | 'fillerPresets' | 'libraries' + | 'traktLists' | 'ffmpegProfiles' | 'watermarks' | 'settings' @@ -321,6 +324,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: