Files
ersatztv/ErsatzTV/Controllers/Api/TraktController.cs
T
timothyandClaude Opus 4.8 ef2bd65c27
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(api): #286 — mount the whole /api surface at /api/v1
Version every /api route to /api/v1 (251 controller routes + ~24 Location
headers + the scanner callback URL + the Startup request-log literal),
uniform across the machine API, auth, scanner and scripted-build surfaces.

Add ApiVersionRewriteMiddleware: a legacy unversioned /api/* request is
rewritten (NOT redirected) to /api/v1/* in-pipeline — method, body, auth
headers and query survive — carrying RFC 8594 Deprecation/Sunset headers,
so curl / the future MCP server / bookmarks keep working. An already-
versioned path passes through; a future /api/v2 is never forced to v1.

Standardize the route convention (leading-slash absolute route per method,
no class-[Route] — except the two Scanner/Scripted controllers whose ~all
actions share a parametrized {id} prefix), enforced by ApiRouteVersioningTests
(^/api/v\d+/ over the whole Controllers.Api surface; browser-nav
/auth/oidc/login is out of scope).

Regenerate v1.json (160 paths, all /api/v1)/endpoint-index/v1.d.ts; sweep 945
SPA request literals + the test mocks (regex + positional URL parsers). /api/v1
is additive-only after freeze; the legacy-rewrite shim sunsets in ~2 releases
(owner decision) with removal tracked as a Phase-3 follow-up.

Docs: decisions.md 2026-07-13, api-conventions §1/§9, rest-api/spa-conventions/
blazor-route-parity/e2e-local/domain-model.

fixes #286
refs #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:30:20 +02:00

249 lines
9.6 KiB
C#

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/v1/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/v1/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/v1/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/v1/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/v1/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/v1/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/v1/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/v1/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;
}
}