using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Text.RegularExpressions; using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Trakt; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] public partial class TraktController( IMediator mediator, ChannelWriter workerChannel, IEntityLocker entityLocker) : ControllerBase { private const int MaxPageSize = 100; [HttpGet("/api/v1/trakt/lists", Name = "GetTraktLists")] [Tags("Trakt")] [EndpointSummary("Get paged Trakt lists")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PagedTraktListsResponseModel), StatusCodes.Status200OK)] public async Task GetAll( [FromQuery] [Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")] int pageNum = 0, [FromQuery] [Description("Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")] 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 GetById(int id, CancellationToken cancellationToken) { Option 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 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 Match(int id, CancellationToken cancellationToken) { Option existing = await mediator.Send(new GetTraktListById(id), cancellationToken); if (existing.IsNone) { return ApiResults.NotFoundProblem(); } return await EnqueueWithTraktLock(new MatchTraktListItems(id), cancellationToken); } [HttpDelete("/api/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 Delete(int id, CancellationToken cancellationToken) { Option existing = await mediator.Send(new GetTraktListById(id), cancellationToken); if (existing.IsNone) { return ApiResults.NotFoundProblem(); } return await EnqueueWithTraktLock(new DeleteTraktList(id), cancellationToken); } [HttpPut("/api/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 Update( int id, [Required][FromBody] UpdateTraktListRequest request, CancellationToken cancellationToken) { Option existing = await mediator.Send(new GetTraktListById(id), cancellationToken); if (existing.IsNone) { return ApiResults.NotFoundProblem(); } Option maybeError = await mediator.Send( new UpdateTraktList(id, request.AutoRefresh, request.GeneratePlaylist), cancellationToken); foreach (BaseError error in maybeError) { return error.ToErrorResult(); } Option updated = await mediator.Send(new GetTraktListById(id), cancellationToken); return updated.Map(ProjectToResponseModel).ToGetResult(); } [HttpGet("/api/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 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; } }