Merge remote-tracking branch 'origin/main' into feat/141-media-browse
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

# Conflicts:
#	ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs
#	web/src/App.tsx
#	web/src/screens/SettingsScreen.tsx
This commit is contained in:
2026-07-07 15:50:03 +02:00
17 changed files with 1909 additions and 2 deletions
@@ -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);
+248
View File
@@ -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;
}
}
+557
View File
@@ -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
View File
@@ -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 />;
}
+24
View File
@@ -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;
+1
View File
@@ -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';
+129
View File
@@ -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' }));
});
});
+70
View File
@@ -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;
}
+1 -1
View File
@@ -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} />
+542
View File
@@ -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>
);
}