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(SettingsController),
|
||||
typeof(SmartCollectionController),
|
||||
typeof(TraktController),
|
||||
typeof(TroubleshootController),
|
||||
typeof(WatermarkController)
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
"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"
|
||||
},
|
||||
|
||||
+30
-1
@@ -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: <Link2 aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Trakt List',
|
||||
placeholder: 'Trakt lists workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
id: 'ffmpegProfiles',
|
||||
path: '/app/ffmpeg-profiles',
|
||||
@@ -391,7 +408,15 @@ const primaryNavIds: ScreenId[] = [
|
||||
'schedules',
|
||||
'playouts'
|
||||
];
|
||||
const mediaNavIds: ScreenId[] = ['media', 'search', 'trash', 'collections', 'fillerPresets', 'libraries'];
|
||||
const mediaNavIds: ScreenId[] = [
|
||||
'media',
|
||||
'search',
|
||||
'trash',
|
||||
'collections',
|
||||
'fillerPresets',
|
||||
'libraries',
|
||||
'traktLists'
|
||||
];
|
||||
const systemNavIds: ScreenId[] = [
|
||||
'settings',
|
||||
'logs',
|
||||
@@ -3034,6 +3059,10 @@ function ScreenContent({
|
||||
return <CollectionsScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'traktLists') {
|
||||
return <TraktListsScreen key={window.location.pathname} />;
|
||||
}
|
||||
|
||||
if (route.id === 'fillerPresets') {
|
||||
return <FillerPresetsScreen />;
|
||||
}
|
||||
|
||||
Vendored
+24
@@ -14,6 +14,9 @@ export interface components {
|
||||
"songIds": null | Array<number>;
|
||||
"imageIds": null | Array<number>;
|
||||
"remoteStreamIds": null | Array<number>;
|
||||
};
|
||||
"AddTraktListRequest": {
|
||||
"url": null | string;
|
||||
};
|
||||
"ArtworkContentTypeModel": {
|
||||
"path": null | string;
|
||||
@@ -598,6 +601,10 @@ export interface components {
|
||||
"PagedPlayoutsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": null | Array<components["schemas"]["PlayoutListItemResponseModel"]>;
|
||||
};
|
||||
"PagedTraktListsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": Array<components["schemas"]["TraktListResponseModel"]>;
|
||||
};
|
||||
"PlaybackOrder": "None" | "Chronological" | "Random" | "Shuffle" | "ShuffleInOrder" | "MultiEpisodeShuffle" | "SeasonEpisode" | "RandomRotation" | "Marathon";
|
||||
"PlaylistViewModel": {
|
||||
@@ -808,6 +815,19 @@ export interface components {
|
||||
"StartType": "Dynamic" | "Fixed";
|
||||
"StreamingMode": "TransportStream" | "HttpLiveStreamingDirect" | "HttpLiveStreamingSegmenter" | "TransportStreamHybrid";
|
||||
"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": {
|
||||
"generalJson": string;
|
||||
"nvidiaCapabilities": null | string;
|
||||
@@ -975,6 +995,10 @@ export interface components {
|
||||
"UpdateSmartCollectionRequest": {
|
||||
"name": null | string;
|
||||
"query": null | string;
|
||||
};
|
||||
"UpdateTraktListRequest": {
|
||||
"autoRefresh": boolean;
|
||||
"generatePlaylist": boolean;
|
||||
};
|
||||
"UpdateUiSettingsRequest": {
|
||||
"isDarkMode": boolean;
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from './playouts';
|
||||
export * from './schedules';
|
||||
export * from './search';
|
||||
export * from './settings';
|
||||
export * from './trakt';
|
||||
export * from './troubleshoot';
|
||||
export * from './useChannelsQuery';
|
||||
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
|
||||
</Button>
|
||||
</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">
|
||||
<span>Open Classic UI</span>
|
||||
<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