Merge remote-tracking branch 'origin/main' into feat/141-media-browse
# Conflicts: # ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs # web/src/App.tsx # web/src/screens/SettingsScreen.tsx
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
#nullable enable
|
||||||
|
namespace ErsatzTV.Core.Api.Trakt;
|
||||||
|
|
||||||
|
public record PagedTraktListsResponseModel(int TotalCount, List<TraktListResponseModel> Page);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#nullable enable
|
||||||
|
namespace ErsatzTV.Core.Api.Trakt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP-observable substitute for the Blazor <c>IEntityLocker.OnTraktChanged</c> event — the SPA polls this
|
||||||
|
/// while an add/match/delete operation is in flight (no push channel exists for the REST API).
|
||||||
|
/// </summary>
|
||||||
|
public record TraktStatusResponseModel(bool Busy);
|
||||||
@@ -41,6 +41,7 @@ public class ApiControllerSecurityTests
|
|||||||
typeof(SessionController),
|
typeof(SessionController),
|
||||||
typeof(SettingsController),
|
typeof(SettingsController),
|
||||||
typeof(SmartCollectionController),
|
typeof(SmartCollectionController),
|
||||||
|
typeof(TraktController),
|
||||||
typeof(TroubleshootController),
|
typeof(TroubleshootController),
|
||||||
typeof(WatermarkController)
|
typeof(WatermarkController)
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -215,6 +215,15 @@ public class OpenApiErrorResponseContractTests
|
|||||||
[TestCase("/api/settings/resolutions/{id}", "delete", "422")]
|
[TestCase("/api/settings/resolutions/{id}", "delete", "422")]
|
||||||
[TestCase("/api/search", "get", "422")]
|
[TestCase("/api/search", "get", "422")]
|
||||||
[TestCase("/api/media-items", "delete", "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(
|
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
|
||||||
string path,
|
string path,
|
||||||
string method,
|
string method,
|
||||||
|
|||||||
@@ -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<IBackgroundServiceRequest> _workerChannel = null!;
|
||||||
|
private TraktController _controller = null!;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_mediator = Substitute.For<IMediator>();
|
||||||
|
_entityLocker = Substitute.For<IEntityLocker>();
|
||||||
|
_workerChannel = Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||||
|
_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<GetPagedTraktLists>(), Arg.Any<CancellationToken>())
|
||||||
|
.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<GetPagedTraktLists>(q => q.PageNum == 0 && q.PageSize == 100),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<TraktListViewModel>.Some(vm));
|
||||||
|
|
||||||
|
IActionResult result = await _controller.GetById(1, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<OkObjectResult>().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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<TraktListViewModel>.None);
|
||||||
|
|
||||||
|
IActionResult result = await _controller.GetById(99, CancellationToken.None);
|
||||||
|
|
||||||
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||||
|
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||||
|
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<UnprocessableEntityObjectResult>().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<AcceptedResult>();
|
||||||
|
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||||
|
request.ShouldBeOfType<AddTraktList>().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<ConflictObjectResult>().StatusCode.ShouldBe(409);
|
||||||
|
_workerChannel.Reader.TryRead(out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Match_Should_Return_404_When_List_Missing()
|
||||||
|
{
|
||||||
|
_mediator.Send(Arg.Any<GetTraktListById>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<TraktListViewModel>.None);
|
||||||
|
|
||||||
|
IActionResult result = await _controller.Match(99, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||||
|
_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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<TraktListViewModel>.Some(vm));
|
||||||
|
_entityLocker.LockTrakt().Returns(true);
|
||||||
|
|
||||||
|
IActionResult result = await _controller.Match(1, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<AcceptedResult>();
|
||||||
|
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||||
|
request.ShouldBeOfType<MatchTraktListItems>().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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<TraktListViewModel>.Some(vm));
|
||||||
|
_entityLocker.LockTrakt().Returns(false);
|
||||||
|
|
||||||
|
IActionResult result = await _controller.Match(1, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<ConflictObjectResult>().StatusCode.ShouldBe(409);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Delete_Should_Return_404_When_List_Missing()
|
||||||
|
{
|
||||||
|
_mediator.Send(Arg.Any<GetTraktListById>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<TraktListViewModel>.None);
|
||||||
|
|
||||||
|
IActionResult result = await _controller.Delete(99, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<TraktListViewModel>.Some(vm));
|
||||||
|
_entityLocker.LockTrakt().Returns(true);
|
||||||
|
|
||||||
|
IActionResult result = await _controller.Delete(1, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<AcceptedResult>();
|
||||||
|
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||||
|
request.ShouldBeOfType<DeleteTraktList>().TraktListId.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Update_Should_Return_404_When_List_Missing()
|
||||||
|
{
|
||||||
|
_mediator.Send(Arg.Any<GetTraktListById>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<TraktListViewModel>.None);
|
||||||
|
|
||||||
|
IActionResult result = await _controller.Update(
|
||||||
|
99,
|
||||||
|
new UpdateTraktListRequest(true, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||||
|
await _mediator.DidNotReceive().Send(Arg.Any<UpdateTraktList>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<TraktListViewModel>.Some(before), Option<TraktListViewModel>.Some(after));
|
||||||
|
_mediator.Send(Arg.Any<UpdateTraktList>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<BaseError>.None);
|
||||||
|
|
||||||
|
IActionResult result = await _controller.Update(
|
||||||
|
1,
|
||||||
|
new UpdateTraktListRequest(true, true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<OkObjectResult>().Value
|
||||||
|
.ShouldBe(new TraktListResponseModel(1, 100, "my-list", "My List", 10, 8, true, true));
|
||||||
|
await _mediator.Received(1).Send(
|
||||||
|
Arg.Is<UpdateTraktList>(c => c.Id == 1 && c.AutoRefresh && c.GeneratePlaylist),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<TraktListViewModel>.Some(vm));
|
||||||
|
_mediator.Send(Arg.Any<UpdateTraktList>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<BaseError>.Some(BaseError.New("bad")));
|
||||||
|
|
||||||
|
IActionResult result = await _controller.Update(
|
||||||
|
1,
|
||||||
|
new UpdateTraktListRequest(true, true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<GetTraktListById>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<TraktListViewModel>.Some(vm));
|
||||||
|
_mediator.Send(Arg.Any<UpdateTraktList>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Option<BaseError>.Some(new NotFoundError("missing")));
|
||||||
|
|
||||||
|
IActionResult result = await _controller.Update(
|
||||||
|
1,
|
||||||
|
new UpdateTraktListRequest(true, true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestCase(true)]
|
||||||
|
[TestCase(false)]
|
||||||
|
public void GetStatus_Should_Wrap_IsTraktLocked(bool busy)
|
||||||
|
{
|
||||||
|
_entityLocker.IsTraktLocked().Returns(busy);
|
||||||
|
|
||||||
|
TraktStatusResponseModel result = _controller.GetStatus();
|
||||||
|
|
||||||
|
result.Busy.ShouldBe(busy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Controllers.Api.Requests;
|
||||||
|
|
||||||
|
public record AddTraktListRequest(string Url);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Controllers.Api.Requests;
|
||||||
|
|
||||||
|
public record UpdateTraktListRequest(bool AutoRefresh, bool GeneratePlaylist);
|
||||||
@@ -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<IBackgroundServiceRequest> 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<PagedTraktListsResponseModel> 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<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Option<TraktListViewModel> 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<IActionResult> 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<IActionResult> Match(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Option<TraktListViewModel> 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<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Option<TraktListViewModel> 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<IActionResult> Update(
|
||||||
|
int id,
|
||||||
|
[Required] [FromBody] UpdateTraktListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Option<TraktListViewModel> existing = await mediator.Send(new GetTraktListById(id), cancellationToken);
|
||||||
|
if (existing.IsNone)
|
||||||
|
{
|
||||||
|
return ApiResults.NotFoundProblem();
|
||||||
|
}
|
||||||
|
|
||||||
|
Option<BaseError> maybeError = await mediator.Send(
|
||||||
|
new UpdateTraktList(id, request.AutoRefresh, request.GeneratePlaylist),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
foreach (BaseError error in maybeError)
|
||||||
|
{
|
||||||
|
return error.ToErrorResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
Option<TraktListViewModel> 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<IActionResult> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {
|
"/api/troubleshoot/info": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -6917,6 +7369,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"AddTraktListRequest": {
|
||||||
|
"required": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ArtworkContentTypeModel": {
|
"ArtworkContentTypeModel": {
|
||||||
"required": [
|
"required": [
|
||||||
"path",
|
"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": {
|
"PlaybackOrder": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"None",
|
"None",
|
||||||
@@ -11189,6 +11674,60 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"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": {
|
"TroubleshootingInfoResponseModel": {
|
||||||
"required": [
|
"required": [
|
||||||
"generalJson",
|
"generalJson",
|
||||||
@@ -12052,6 +12591,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"UpdateTraktListRequest": {
|
||||||
|
"required": [
|
||||||
|
"autoRefresh",
|
||||||
|
"generatePlaylist"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"autoRefresh": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"generatePlaylist": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UpdateUiSettingsRequest": {
|
"UpdateUiSettingsRequest": {
|
||||||
"required": [
|
"required": [
|
||||||
"isDarkMode",
|
"isDarkMode",
|
||||||
@@ -12504,6 +13058,9 @@
|
|||||||
{
|
{
|
||||||
"name": "Smart Collections"
|
"name": "Smart Collections"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Trakt"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Troubleshooting"
|
"name": "Troubleshooting"
|
||||||
},
|
},
|
||||||
|
|||||||
+30
-1
@@ -33,6 +33,7 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Library,
|
Library,
|
||||||
|
Link2,
|
||||||
ListVideo,
|
ListVideo,
|
||||||
MonitorPlay,
|
MonitorPlay,
|
||||||
Music,
|
Music,
|
||||||
@@ -65,6 +66,7 @@ import { LogsScreen } from './screens/LogsScreen';
|
|||||||
import { MediaBrowseScreen } from './screens/MediaBrowseScreen';
|
import { MediaBrowseScreen } from './screens/MediaBrowseScreen';
|
||||||
import { SearchScreen } from './screens/SearchScreen';
|
import { SearchScreen } from './screens/SearchScreen';
|
||||||
import { SettingsScreen } from './screens/SettingsScreen';
|
import { SettingsScreen } from './screens/SettingsScreen';
|
||||||
|
import { TraktListsScreen } from './screens/TraktListsScreen';
|
||||||
import { TrashScreen } from './screens/TrashScreen';
|
import { TrashScreen } from './screens/TrashScreen';
|
||||||
import { TroubleshootingScreen } from './screens/TroubleshootingScreen';
|
import { TroubleshootingScreen } from './screens/TroubleshootingScreen';
|
||||||
import { WatermarksScreen } from './screens/WatermarksScreen';
|
import { WatermarksScreen } from './screens/WatermarksScreen';
|
||||||
@@ -147,6 +149,7 @@ type ScreenId =
|
|||||||
| 'collections'
|
| 'collections'
|
||||||
| 'fillerPresets'
|
| 'fillerPresets'
|
||||||
| 'libraries'
|
| 'libraries'
|
||||||
|
| 'traktLists'
|
||||||
| 'ffmpegProfiles'
|
| 'ffmpegProfiles'
|
||||||
| 'watermarks'
|
| 'watermarks'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
@@ -321,6 +324,20 @@ const routes: ScreenRoute[] = [
|
|||||||
primaryAction: 'Scan',
|
primaryAction: 'Scan',
|
||||||
placeholder: 'Libraries workspace'
|
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: <Link2 aria-hidden="true" size={16} />,
|
||||||
|
primaryAction: 'Add Trakt List',
|
||||||
|
placeholder: 'Trakt lists workspace',
|
||||||
|
allowSubPaths: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'ffmpegProfiles',
|
id: 'ffmpegProfiles',
|
||||||
path: '/app/ffmpeg-profiles',
|
path: '/app/ffmpeg-profiles',
|
||||||
@@ -391,7 +408,15 @@ const primaryNavIds: ScreenId[] = [
|
|||||||
'schedules',
|
'schedules',
|
||||||
'playouts'
|
'playouts'
|
||||||
];
|
];
|
||||||
const mediaNavIds: ScreenId[] = ['media', 'search', 'trash', 'collections', 'fillerPresets', 'libraries'];
|
const mediaNavIds: ScreenId[] = [
|
||||||
|
'media',
|
||||||
|
'search',
|
||||||
|
'trash',
|
||||||
|
'collections',
|
||||||
|
'fillerPresets',
|
||||||
|
'libraries',
|
||||||
|
'traktLists'
|
||||||
|
];
|
||||||
const systemNavIds: ScreenId[] = [
|
const systemNavIds: ScreenId[] = [
|
||||||
'settings',
|
'settings',
|
||||||
'logs',
|
'logs',
|
||||||
@@ -3034,6 +3059,10 @@ function ScreenContent({
|
|||||||
return <CollectionsScreen />;
|
return <CollectionsScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route.id === 'traktLists') {
|
||||||
|
return <TraktListsScreen key={window.location.pathname} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (route.id === 'fillerPresets') {
|
if (route.id === 'fillerPresets') {
|
||||||
return <FillerPresetsScreen />;
|
return <FillerPresetsScreen />;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+24
@@ -14,6 +14,9 @@ export interface components {
|
|||||||
"songIds": null | Array<number>;
|
"songIds": null | Array<number>;
|
||||||
"imageIds": null | Array<number>;
|
"imageIds": null | Array<number>;
|
||||||
"remoteStreamIds": null | Array<number>;
|
"remoteStreamIds": null | Array<number>;
|
||||||
|
};
|
||||||
|
"AddTraktListRequest": {
|
||||||
|
"url": null | string;
|
||||||
};
|
};
|
||||||
"ArtworkContentTypeModel": {
|
"ArtworkContentTypeModel": {
|
||||||
"path": null | string;
|
"path": null | string;
|
||||||
@@ -598,6 +601,10 @@ export interface components {
|
|||||||
"PagedPlayoutsResponseModel": {
|
"PagedPlayoutsResponseModel": {
|
||||||
"totalCount": number;
|
"totalCount": number;
|
||||||
"page": null | Array<components["schemas"]["PlayoutListItemResponseModel"]>;
|
"page": null | Array<components["schemas"]["PlayoutListItemResponseModel"]>;
|
||||||
|
};
|
||||||
|
"PagedTraktListsResponseModel": {
|
||||||
|
"totalCount": number;
|
||||||
|
"page": Array<components["schemas"]["TraktListResponseModel"]>;
|
||||||
};
|
};
|
||||||
"PlaybackOrder": "None" | "Chronological" | "Random" | "Shuffle" | "ShuffleInOrder" | "MultiEpisodeShuffle" | "SeasonEpisode" | "RandomRotation" | "Marathon";
|
"PlaybackOrder": "None" | "Chronological" | "Random" | "Shuffle" | "ShuffleInOrder" | "MultiEpisodeShuffle" | "SeasonEpisode" | "RandomRotation" | "Marathon";
|
||||||
"PlaylistViewModel": {
|
"PlaylistViewModel": {
|
||||||
@@ -808,6 +815,19 @@ export interface components {
|
|||||||
"StartType": "Dynamic" | "Fixed";
|
"StartType": "Dynamic" | "Fixed";
|
||||||
"StreamingMode": "TransportStream" | "HttpLiveStreamingDirect" | "HttpLiveStreamingSegmenter" | "TransportStreamHybrid";
|
"StreamingMode": "TransportStream" | "HttpLiveStreamingDirect" | "HttpLiveStreamingSegmenter" | "TransportStreamHybrid";
|
||||||
"TailMode": "None" | "Offline" | "Slate" | "Filler";
|
"TailMode": "None" | "Offline" | "Slate" | "Filler";
|
||||||
|
"TraktListResponseModel": {
|
||||||
|
"id": number;
|
||||||
|
"traktId": number;
|
||||||
|
"slug": string;
|
||||||
|
"name": string;
|
||||||
|
"itemCount": number;
|
||||||
|
"matchCount": number;
|
||||||
|
"autoRefresh": boolean;
|
||||||
|
"generatePlaylist": boolean;
|
||||||
|
};
|
||||||
|
"TraktStatusResponseModel": {
|
||||||
|
"busy": boolean;
|
||||||
|
};
|
||||||
"TroubleshootingInfoResponseModel": {
|
"TroubleshootingInfoResponseModel": {
|
||||||
"generalJson": string;
|
"generalJson": string;
|
||||||
"nvidiaCapabilities": null | string;
|
"nvidiaCapabilities": null | string;
|
||||||
@@ -975,6 +995,10 @@ export interface components {
|
|||||||
"UpdateSmartCollectionRequest": {
|
"UpdateSmartCollectionRequest": {
|
||||||
"name": null | string;
|
"name": null | string;
|
||||||
"query": null | string;
|
"query": null | string;
|
||||||
|
};
|
||||||
|
"UpdateTraktListRequest": {
|
||||||
|
"autoRefresh": boolean;
|
||||||
|
"generatePlaylist": boolean;
|
||||||
};
|
};
|
||||||
"UpdateUiSettingsRequest": {
|
"UpdateUiSettingsRequest": {
|
||||||
"isDarkMode": boolean;
|
"isDarkMode": boolean;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export * from './playouts';
|
|||||||
export * from './schedules';
|
export * from './schedules';
|
||||||
export * from './search';
|
export * from './search';
|
||||||
export * from './settings';
|
export * from './settings';
|
||||||
|
export * from './trakt';
|
||||||
export * from './troubleshoot';
|
export * from './troubleshoot';
|
||||||
export * from './useChannelsQuery';
|
export * from './useChannelsQuery';
|
||||||
export * from './watermarks';
|
export * from './watermarks';
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
addTraktList,
|
||||||
|
deleteTraktList,
|
||||||
|
getTraktListById,
|
||||||
|
getTraktLists,
|
||||||
|
getTraktStatus,
|
||||||
|
matchTraktList,
|
||||||
|
updateTraktList
|
||||||
|
} from './trakt';
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function accepted(): Response {
|
||||||
|
return new Response(null, { headers: { 'Content-Length': '0' }, status: 202 });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('trakt api client', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTraktLists fetches the default page without query params', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.spyOn(window, 'fetch')
|
||||||
|
.mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
||||||
|
|
||||||
|
await getTraktLists();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists', expect.objectContaining({ method: 'GET' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTraktLists forwards pageNum/pageSize as query params', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.spyOn(window, 'fetch')
|
||||||
|
.mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
||||||
|
|
||||||
|
await getTraktLists({ pageNum: 2, pageSize: 25 });
|
||||||
|
|
||||||
|
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
|
||||||
|
expect(url.pathname).toBe('/api/trakt/lists');
|
||||||
|
expect(url.searchParams.get('pageNum')).toBe('2');
|
||||||
|
expect(url.searchParams.get('pageSize')).toBe('25');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTraktListById fetches a single list by id', async () => {
|
||||||
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
autoRefresh: true,
|
||||||
|
generatePlaylist: false,
|
||||||
|
id: 1,
|
||||||
|
itemCount: 10,
|
||||||
|
matchCount: 8,
|
||||||
|
name: 'My List',
|
||||||
|
slug: 'my-list',
|
||||||
|
traktId: 100
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(getTraktListById(1)).resolves.toMatchObject({ id: 1, slug: 'my-list' });
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists/1', expect.objectContaining({ method: 'GET' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addTraktList POSTs the url and resolves on 202', async () => {
|
||||||
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(accepted());
|
||||||
|
|
||||||
|
await expect(addTraktList('https://trakt.tv/users/someuser/lists/some-list')).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/trakt/lists');
|
||||||
|
expect(init).toMatchObject({ method: 'POST' });
|
||||||
|
expect(JSON.parse(String(init?.body))).toEqual({ url: 'https://trakt.tv/users/someuser/lists/some-list' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addTraktList rethrows a 422 for an invalid url', async () => {
|
||||||
|
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ detail: 'Invalid Trakt list url', status: 422 }, 422));
|
||||||
|
|
||||||
|
await expect(addTraktList('not-a-url')).rejects.toMatchObject({ status: 422 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matchTraktList POSTs to the match sub-route', async () => {
|
||||||
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(accepted());
|
||||||
|
|
||||||
|
await expect(matchTraktList(5)).resolves.toBeUndefined();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists/5/match', expect.objectContaining({ method: 'POST' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteTraktList issues a DELETE and resolves on 202', async () => {
|
||||||
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(accepted());
|
||||||
|
|
||||||
|
await expect(deleteTraktList(9)).resolves.toBeUndefined();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists/9', expect.objectContaining({ method: 'DELETE' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateTraktList PUTs autoRefresh/generatePlaylist', async () => {
|
||||||
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
autoRefresh: true,
|
||||||
|
generatePlaylist: true,
|
||||||
|
id: 3,
|
||||||
|
itemCount: 10,
|
||||||
|
matchCount: 8,
|
||||||
|
name: 'My List',
|
||||||
|
slug: 'my-list',
|
||||||
|
traktId: 100
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateTraktList(3, { autoRefresh: true, generatePlaylist: true });
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/trakt/lists/3');
|
||||||
|
expect(init).toMatchObject({ method: 'PUT' });
|
||||||
|
expect(JSON.parse(String(init?.body))).toEqual({ autoRefresh: true, generatePlaylist: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTraktStatus fetches the busy flag', async () => {
|
||||||
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ busy: true }));
|
||||||
|
|
||||||
|
await expect(getTraktStatus()).resolves.toEqual({ busy: true });
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/status', expect.objectContaining({ method: 'GET' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { ApiError, request } from './client';
|
||||||
|
import type { components } from './generated/v1';
|
||||||
|
|
||||||
|
export type TraktList = components['schemas']['TraktListResponseModel'];
|
||||||
|
export type PagedTraktLists = components['schemas']['PagedTraktListsResponseModel'];
|
||||||
|
export type TraktStatus = components['schemas']['TraktStatusResponseModel'];
|
||||||
|
export type AddTraktListRequest = components['schemas']['AddTraktListRequest'];
|
||||||
|
export type UpdateTraktListRequest = components['schemas']['UpdateTraktListRequest'];
|
||||||
|
|
||||||
|
export interface GetTraktListsParams {
|
||||||
|
pageNum?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTraktLists(params: GetTraktListsParams = {}): Promise<PagedTraktLists> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params.pageNum != null) {
|
||||||
|
searchParams.set('pageNum', String(params.pageNum));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.pageSize != null) {
|
||||||
|
searchParams.set('pageSize', String(params.pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
|
return request<PagedTraktLists>(`/api/trakt/lists${queryString ? `?${queryString}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTraktListById(id: number): Promise<TraktList> {
|
||||||
|
return request<TraktList>(`/api/trakt/lists/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 202 Accepted: the server dispatches to the same background worker channel the classic
|
||||||
|
// UI's "Add Trakt List" dialog uses. Fetch/save/match all happen asynchronously — poll
|
||||||
|
// getTraktStatus() and reload the list once it goes idle.
|
||||||
|
export function addTraktList(url: string): Promise<void> {
|
||||||
|
return request<void>('/api/trakt/lists', { body: { url } satisfies AddTraktListRequest, method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 202 Accepted; see addTraktList for the async/poll pattern.
|
||||||
|
export function matchTraktList(id: number): Promise<void> {
|
||||||
|
return request<void>(`/api/trakt/lists/${id}/match`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 202 Accepted; see addTraktList for the async/poll pattern.
|
||||||
|
export function deleteTraktList(id: number): Promise<void> {
|
||||||
|
return request<void>(`/api/trakt/lists/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTraktList(id: number, body: UpdateTraktListRequest): Promise<TraktList> {
|
||||||
|
return request<TraktList>(`/api/trakt/lists/${id}`, { body, method: 'PUT' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTraktStatus(): Promise<TraktStatus> {
|
||||||
|
return request<TraktStatus>('/api/trakt/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageFromTraktError(error: unknown, fallback = 'Unable to load Trakt lists'): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return error.detail ?? error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -861,7 +861,7 @@ function SystemPane({
|
|||||||
Open Dashboard
|
Open Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
</Row>
|
</Row>
|
||||||
<Row control={220} help="Trakt, blocks/decos/templates and playout editors still live here." label="Classic UI">
|
<Row control={220} help="Blocks/decos/templates and playout editors still live here." label="Classic UI">
|
||||||
<a className="ctv-button ctv-button-secondary ctv-button-sm" href="/system/health">
|
<a className="ctv-button ctv-button-secondary ctv-button-sm" href="/system/health">
|
||||||
<span>Open Classic UI</span>
|
<span>Open Classic UI</span>
|
||||||
<ExternalLink aria-hidden="true" size={13} />
|
<ExternalLink aria-hidden="true" size={13} />
|
||||||
|
|||||||
@@ -0,0 +1,542 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { ArrowLeft, ExternalLink, Pencil, Plus, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
|
||||||
|
import { navigateToPath } from '../routing';
|
||||||
|
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Spinner, Switch } from '../components';
|
||||||
|
import {
|
||||||
|
addTraktList,
|
||||||
|
deleteTraktList,
|
||||||
|
getTraktListById,
|
||||||
|
getTraktLists,
|
||||||
|
getTraktStatus,
|
||||||
|
matchTraktList,
|
||||||
|
messageFromTraktError,
|
||||||
|
updateTraktList,
|
||||||
|
type TraktList
|
||||||
|
} from '../api';
|
||||||
|
|
||||||
|
const TRAKT_BASE_PATH = '/app/trakt-lists';
|
||||||
|
|
||||||
|
// The SPA has no search screen yet (#161) — "view matched items" opens the classic Blazor
|
||||||
|
// search page, same interim deep-link pattern used elsewhere for un-migrated screens.
|
||||||
|
function classicSearchUrl(traktId: number): string {
|
||||||
|
const params = new URLSearchParams({ query: `trakt_list:${traktId}` });
|
||||||
|
return `/search?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function traktListIdFromPathname(pathname: string): number | null {
|
||||||
|
const normalized = pathname.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
if (!normalized.startsWith(`${TRAKT_BASE_PATH}/`)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(normalized.slice(TRAKT_BASE_PATH.length + 1).split('/')[0]);
|
||||||
|
return Number.isInteger(id) && id > 0 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The status endpoint is a poll-only substitute for the Blazor page's live
|
||||||
|
// IEntityLocker.OnTraktChanged event (no push channel exists for the REST API). Poll while
|
||||||
|
// busy; stop once idle; the caller re-fetches its own data on the busy -> idle transition.
|
||||||
|
function useTraktBusyPoll(onIdleTransition: () => void) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const wasBusyRef = useRef(false);
|
||||||
|
const timerRef = useRef<number | undefined>(undefined);
|
||||||
|
const onIdleTransitionRef = useRef(onIdleTransition);
|
||||||
|
// Indirection so the recursive setTimeout call always reaches the latest poll closure
|
||||||
|
// without the callback needing to reference its own (not-yet-assigned) binding.
|
||||||
|
const pollRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onIdleTransitionRef.current = onIdleTransition;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pollRef.current = () => {
|
||||||
|
getTraktStatus()
|
||||||
|
.then((status) => {
|
||||||
|
setBusy(status.busy);
|
||||||
|
|
||||||
|
if (wasBusyRef.current && !status.busy) {
|
||||||
|
onIdleTransitionRef.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
wasBusyRef.current = status.busy;
|
||||||
|
|
||||||
|
if (status.busy) {
|
||||||
|
timerRef.current = window.setTimeout(() => pollRef.current(), 2500);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Give up silently; the next dispatched action restarts polling via markBusy().
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pollRef.current();
|
||||||
|
return () => window.clearTimeout(timerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markBusy = useCallback(() => {
|
||||||
|
setBusy(true);
|
||||||
|
wasBusyRef.current = true;
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = window.setTimeout(() => pollRef.current(), 2500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { busy, markBusy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- add dialog ---------- */
|
||||||
|
|
||||||
|
function AddTraktListDialog({
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
open
|
||||||
|
}: {
|
||||||
|
busy: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (url: string) => void;
|
||||||
|
open: boolean;
|
||||||
|
}) {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const trimmed = url.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button disabled={busy} onClick={onCancel} variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busy || trimmed.length === 0} loading={busy} onClick={() => onSubmit(trimmed)} variant="primary">
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onClose={onCancel}
|
||||||
|
open={open}
|
||||||
|
title="Add Trakt list"
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label="Trakt list URL"
|
||||||
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
|
placeholder="https://trakt.tv/users/username/lists/list-name"
|
||||||
|
value={url}
|
||||||
|
/>
|
||||||
|
<p className="ctv-collections-picker-note">
|
||||||
|
Fetching, saving and matching happen in the background — this dialog closes right away and the list appears
|
||||||
|
once the server finishes.
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<span className="ctv-field-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- sub-path editor ---------- */
|
||||||
|
|
||||||
|
function TraktListEditor({
|
||||||
|
id,
|
||||||
|
onBack,
|
||||||
|
onBackgroundMatch,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
onBack: () => void;
|
||||||
|
onBackgroundMatch: () => void;
|
||||||
|
}) {
|
||||||
|
const [list, setList] = useState<TraktList | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
const [generatePlaylist, setGeneratePlaylist] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
getTraktListById(id)
|
||||||
|
.then((fetched) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setList(fetched);
|
||||||
|
setAutoRefresh(fetched.autoRefresh);
|
||||||
|
setGeneratePlaylist(fetched.generatePlaylist);
|
||||||
|
})
|
||||||
|
.catch((fetchError: unknown) => {
|
||||||
|
if (active) {
|
||||||
|
setError(messageFromTraktError(fetchError, 'Unable to load Trakt list'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateTraktList(id, { autoRefresh, generatePlaylist });
|
||||||
|
setList(updated);
|
||||||
|
setSaved(true);
|
||||||
|
if (generatePlaylist) {
|
||||||
|
// saving with generatePlaylist enqueues a background match server-side
|
||||||
|
onBackgroundMatch();
|
||||||
|
}
|
||||||
|
} catch (updateError) {
|
||||||
|
setSaveError(messageFromTraktError(updateError, 'Unable to save Trakt list'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ctv-collections">
|
||||||
|
<div className="ctv-channels-actionbar">
|
||||||
|
<Button onClick={onBack} size="sm" startIcon={<ArrowLeft aria-hidden="true" size={14} />} variant="ghost">
|
||||||
|
All Trakt lists
|
||||||
|
</Button>
|
||||||
|
<span className="ctv-collections-detail-title">{list?.name ?? `Trakt list ${id}`}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="ctv-channels-error" role="alert">
|
||||||
|
<TriangleAlert aria-hidden="true" size={15} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
{loading ? (
|
||||||
|
<div className="ctv-collections-loading">
|
||||||
|
<Spinner size={18} />
|
||||||
|
<span>Loading Trakt list…</span>
|
||||||
|
</div>
|
||||||
|
) : list ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<Input disabled label="Slug" value={list.slug} />
|
||||||
|
<label className="ctv-collections-order-toggle" title="Automatically refresh this list's items">
|
||||||
|
<Switch checked={autoRefresh} onChange={setAutoRefresh} size="sm" />
|
||||||
|
<span>Auto refresh</span>
|
||||||
|
</label>
|
||||||
|
<label className="ctv-collections-order-toggle" title="Generate a system playlist from this list">
|
||||||
|
<Switch checked={generatePlaylist} onChange={setGeneratePlaylist} size="sm" />
|
||||||
|
<span>Generate playlist</span>
|
||||||
|
</label>
|
||||||
|
{saveError && (
|
||||||
|
<span className="ctv-field-error" role="alert">
|
||||||
|
{saveError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{saved && !saveError && <Badge tone="ok">Saved</Badge>}
|
||||||
|
<div>
|
||||||
|
<Button disabled={saving} loading={saving} onClick={() => void save()} variant="primary">
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- screen ---------- */
|
||||||
|
|
||||||
|
export function TraktListsScreen() {
|
||||||
|
const editingId = traktListIdFromPathname(window.location.pathname);
|
||||||
|
|
||||||
|
const [lists, setLists] = useState<TraktList[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [addBusy, setAddBusy] = useState(false);
|
||||||
|
const [addError, setAddError] = useState<string | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<TraktList | null>(null);
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [rowError, setRowError] = useState<string | null>(null);
|
||||||
|
const activeRef = useRef(true);
|
||||||
|
|
||||||
|
// Fetch only; state updates happen in the async callbacks (never synchronously in the
|
||||||
|
// effect body). `loading` starts true and flips false in `finally`, so the mount effect
|
||||||
|
// shows the spinner; later quiet reloads (e.g. after a busy -> idle transition) never
|
||||||
|
// touch `loading`, so the table stays visible instead of flashing back to a spinner.
|
||||||
|
const load = useCallback(() => {
|
||||||
|
getTraktLists({ pageSize: 100 })
|
||||||
|
.then((paged) => {
|
||||||
|
if (activeRef.current) {
|
||||||
|
setLists(paged.page ?? []);
|
||||||
|
setTotalCount(paged.totalCount ?? 0);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((loadError: unknown) => {
|
||||||
|
if (activeRef.current) {
|
||||||
|
setError(messageFromTraktError(loadError));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (activeRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeRef.current = true;
|
||||||
|
load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
activeRef.current = false;
|
||||||
|
};
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const { busy, markBusy } = useTraktBusyPoll(load);
|
||||||
|
|
||||||
|
if (editingId !== null) {
|
||||||
|
return (
|
||||||
|
<TraktListEditor
|
||||||
|
id={editingId}
|
||||||
|
onBack={() => navigateToPath(TRAKT_BASE_PATH)}
|
||||||
|
onBackgroundMatch={markBusy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitAdd = async (url: string) => {
|
||||||
|
setAddBusy(true);
|
||||||
|
setAddError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addTraktList(url);
|
||||||
|
markBusy();
|
||||||
|
setAddOpen(false);
|
||||||
|
} catch (submitError) {
|
||||||
|
setAddError(messageFromTraktError(submitError, 'Unable to add Trakt list'));
|
||||||
|
} finally {
|
||||||
|
setAddBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const match = async (list: TraktList) => {
|
||||||
|
setRowError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await matchTraktList(list.id);
|
||||||
|
markBusy();
|
||||||
|
} catch (matchError) {
|
||||||
|
setRowError(messageFromTraktError(matchError, 'Unable to match Trakt list items'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteBusy(true);
|
||||||
|
setDeleteError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteTraktList(deleteTarget.id);
|
||||||
|
markBusy();
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} catch (removeError) {
|
||||||
|
setDeleteError(messageFromTraktError(removeError, 'Unable to delete Trakt list'));
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ctv-collections">
|
||||||
|
<div className="ctv-channels-actionbar">
|
||||||
|
<span className="ctv-collections-detail-title">Trakt Lists</span>
|
||||||
|
{busy && (
|
||||||
|
<Badge tone="accent">
|
||||||
|
<Spinner size={12} /> Busy
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="ctv-channels-spacer" />
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setAddError(null);
|
||||||
|
setAddOpen(true);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
startIcon={<Plus aria-hidden="true" size={14} />}
|
||||||
|
>
|
||||||
|
Add Trakt list
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="ctv-channels-error" role="alert">
|
||||||
|
<TriangleAlert aria-hidden="true" size={15} />
|
||||||
|
<span>{error}</span>
|
||||||
|
<span className="ctv-channels-spacer" />
|
||||||
|
<Button onClick={refresh} size="sm" variant="secondary">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rowError && (
|
||||||
|
<div className="ctv-channels-error" role="alert">
|
||||||
|
<TriangleAlert aria-hidden="true" size={15} />
|
||||||
|
<span>{rowError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card padded={false}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="ctv-collections-loading" role="status">
|
||||||
|
<Spinner size={18} />
|
||||||
|
<span>Loading Trakt lists…</span>
|
||||||
|
</div>
|
||||||
|
) : lists.length === 0 ? (
|
||||||
|
<div className="ctv-collections-empty">No Trakt lists yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="ctv-channels-table-frame">
|
||||||
|
<div className="ctv-channels-table-scroll">
|
||||||
|
<table aria-label="Trakt lists" className="ctv-channels-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Match status</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lists.map((list) => (
|
||||||
|
<tr key={list.id}>
|
||||||
|
<td style={{ fontFamily: 'var(--font-mono)' }}>{list.slug}</td>
|
||||||
|
<td>{list.name}</td>
|
||||||
|
<td>
|
||||||
|
{list.matchCount} of {list.itemCount}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ alignItems: 'center', display: 'flex', gap: 4 }}>
|
||||||
|
<IconButton
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => navigateToPath(`${TRAKT_BASE_PATH}/${list.id}`)}
|
||||||
|
size="sm"
|
||||||
|
title="Edit Trakt list properties"
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden="true" size={14} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void match(list)}
|
||||||
|
size="sm"
|
||||||
|
title="Match Trakt list items"
|
||||||
|
>
|
||||||
|
<RefreshCw aria-hidden="true" size={14} />
|
||||||
|
</IconButton>
|
||||||
|
<a
|
||||||
|
className="ctv-icon-button ctv-icon-button-sm"
|
||||||
|
href={classicSearchUrl(list.traktId)}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
title="View matched items (opens the Classic UI search page)"
|
||||||
|
>
|
||||||
|
<ExternalLink aria-hidden="true" size={14} />
|
||||||
|
</a>
|
||||||
|
<IconButton
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setDeleteTarget(list)}
|
||||||
|
size="sm"
|
||||||
|
title="Delete Trakt list"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" size={14} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="ctv-channels-footer">
|
||||||
|
<span>
|
||||||
|
{totalCount} list{totalCount === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="ctv-settings-callout">
|
||||||
|
<Search aria-hidden="true" size={14} />
|
||||||
|
<span>
|
||||||
|
"View matched items" opens the Classic UI search page — the SPA doesn't have a search screen yet.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddTraktListDialog
|
||||||
|
busy={addBusy}
|
||||||
|
error={addError}
|
||||||
|
key={`add-${addOpen}`}
|
||||||
|
onCancel={() => setAddOpen(false)}
|
||||||
|
onSubmit={(url) => void submitAdd(url)}
|
||||||
|
open={addOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
busy={deleteBusy}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
message={
|
||||||
|
deleteTarget ? (
|
||||||
|
<>
|
||||||
|
<span>{`Delete "${deleteTarget.name}"? This cannot be undone.`}</span>
|
||||||
|
{deleteError && (
|
||||||
|
<span className="ctv-field-error" role="alert">
|
||||||
|
{deleteError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onCancel={() => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteError(null);
|
||||||
|
}}
|
||||||
|
onConfirm={() => void confirmDelete()}
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
title="Delete Trakt list"
|
||||||
|
tone="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user