using System.ComponentModel.DataAnnotations; using System.Globalization; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Application.Scheduling; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.MediaCollections; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] public class PlaylistController(IMediator mediator) : ControllerBase { [HttpGet("/api/v1/playlists/groups", Name = "GetPlaylistGroups")] [Tags("Playlists")] [EndpointSummary("Get all playlist groups")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetGroups(CancellationToken cancellationToken) { List groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken); return groups.Map(ProjectToGroupResponse).ToList(); } [HttpPost("/api/v1/playlists/groups", Name = "CreatePlaylistGroup")] [Tags("Playlists")] [EndpointSummary("Create a playlist group")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PlaylistGroupResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task CreateGroup( [Required][FromBody] CreatePlaylistGroupRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToCreatedResult( g => $"/api/v1/playlists/groups/{g.Id}", ProjectToGroupResponse); } [HttpPut("/api/v1/playlists/groups/{id:int}", Name = "UpdatePlaylistGroup")] [Tags("Playlists")] [EndpointSummary("Rename a playlist group")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PlaylistGroupResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task UpdateGroup( int id, [Required][FromBody] UpdatePlaylistGroupRequest request, CancellationToken cancellationToken) { // Existence pre-check (404) mirrors DeleteGroup. Required controller-side because the // handler's NotFoundError is collapsed to a plain BaseError by LanguageExtensions.Apply // (error.Join()), so a missing group would otherwise map to 422 instead of 404. List groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken); Option maybeGroup = groups.Find(g => g.Id == id); if (maybeGroup.IsNone) { return ApiResults.NotFoundProblem(); } // System (generated) groups must not be renamed. The RenamePlaylistGroupHandler also // enforces this (defense-in-depth for the Blazor path); catching it here keeps the API's // 422 independent of the handler's error-collapsing. foreach (PlaylistGroupViewModel group in maybeGroup) { if (group.IsSystem) { return BaseError.New("Cannot rename system playlist group").ToErrorResult(); } } Either result = await mediator.Send(request.ToCommand(id), cancellationToken); return result.Match( Left: error => error.ToErrorResult(), Right: vm => (IActionResult)new OkObjectResult(ProjectToGroupResponse(vm))); } [HttpDelete("/api/v1/playlists/groups/{id:int}", Name = "DeletePlaylistGroup")] [Tags("Playlists")] [EndpointSummary("Delete a playlist group")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task DeleteGroup(int id, CancellationToken cancellationToken) { List groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken); if (groups.All(g => g.Id != id)) { return ApiResults.NotFoundProblem(); } Option result = await mediator.Send(new DeletePlaylistGroup(id), cancellationToken); return result.Match( Some: error => error.ToErrorResult(), None: () => new NoContentResult()); } [HttpGet("/api/v1/playlists", Name = "GetPlaylists")] [Tags("Playlists")] [EndpointSummary("Get playlists in a playlist group")] [EndpointDescription("Returns the playlists in the given playlist group.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetAll( [FromQuery] int playlistGroupId, CancellationToken cancellationToken) { List playlists = await mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroupId), cancellationToken); return playlists.Map(ProjectToPlaylistResponse).ToList(); } [HttpGet("/api/v1/playlists/{id:int}", Name = "GetPlaylistById")] [Tags("Playlists")] [EndpointSummary("Get a playlist by id")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PlaylistResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetPlaylistById(id), cancellationToken); return result.Map(ProjectToPlaylistResponse).ToGetResult(); } [HttpGet("/api/v1/playlists/{id:int}/items", Name = "GetPlaylistItems")] [Tags("Playlists")] [EndpointSummary("Get the items in a playlist")] [EndpointDescription( "Returns the playlist's items and a strong ETag of the playlist's version. Pass that ETag back " + "as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetItems(int id, CancellationToken cancellationToken) { Option maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken); if (maybePlaylist.IsNone) { return ApiResults.NotFoundProblem(); } // The items GET returns children, not the root, so read the playlist's version for the ETag. ConcurrencyHeaders.SetETag(Response, maybePlaylist.Map(p => p.Version).IfNone(0)); List items = await mediator.Send(new GetPlaylistItems(id), cancellationToken); return new OkObjectResult(items.Map(ProjectToItemResponse).ToList()); } [HttpPost("/api/v1/playlists", Name = "CreatePlaylistInGroup")] [Tags("Playlists")] [EndpointSummary("Create a playlist")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PlaylistResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Create( [Required][FromBody] CreatePlaylistRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToCreatedResult( p => $"/api/v1/playlists/{p.Id}", ProjectToPlaylistResponse); } [HttpPut("/api/v1/playlists/{id:int}", Name = "UpdatePlaylist")] [Tags("Playlists")] [EndpointSummary("Update a playlist (rename and replace its items)")] [EndpointDescription( "Replaces the playlist's name and its full item list. Item indexes are assigned from the array " + "order. Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 " + "(issue #253); a successful response carries the new ETag.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Update( int id, [Required][FromBody] ReplacePlaylistRequest request, CancellationToken cancellationToken) { IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request); if (ifMatch.Kind is IfMatchKind.Malformed) { return new BadRequestObjectResult( new ProblemDetails { Status = StatusCodes.Status400BadRequest, Title = "Invalid If-Match header", Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"." }); } Option maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken); if (maybePlaylist.IsNone) { return ApiResults.NotFoundProblem(); } // System (generated) playlists must not be renamed or have their items replaced. // Mirrors the DeletePlaylist system-guard (422); done controller-side so item // replacement never reaches ReplacePlaylistItemsHandler for a generated playlist. foreach (PlaylistViewModel playlist in maybePlaylist) { if (playlist.IsSystem) { return BaseError.New("Cannot modify system (generated) playlist").ToErrorResult(); } } Either> result = await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersions), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { // Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than // the returned items (issue #253 fail-safe ordering; matches BlockController). A None root // (deleted between commit and reload) is a 404, never a 200 without an ETag. Option refreshed = await mediator.Send(new GetPlaylistById(id), cancellationToken); List items = await mediator.Send(new GetPlaylistItems(id), cancellationToken); return refreshed.Match( Some: vm => { ConcurrencyHeaders.SetETag(Response, vm.Version); return (IActionResult)new OkObjectResult(items.Map(ProjectToItemResponse).ToList()); }, None: () => ApiResults.NotFoundProblem()); }); } [HttpDelete("/api/v1/playlists/{id:int}", Name = "DeletePlaylist")] [Tags("Playlists")] [EndpointSummary("Delete a playlist")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Delete(int id, CancellationToken cancellationToken) { Option maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken); if (maybePlaylist.IsNone) { return ApiResults.NotFoundProblem(); } Option result = await mediator.Send(new DeletePlaylist(id), cancellationToken); return result.Match( Some: error => error.ToErrorResult(), None: () => new NoContentResult()); } [HttpPost("/api/v1/playlists/{id:int}/items", Name = "AddItemsToPlaylist")] [Tags("Playlists")] [EndpointSummary("Add items to a playlist")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task AddItems( int id, [Required][FromBody] AddItemsToPlaylistRequest request, CancellationToken cancellationToken) { Option maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken); if (maybePlaylist.IsNone) { return ApiResults.NotFoundProblem(); } // System (generated) playlists must not have items added. The AddItemsToPlaylistHandler // also enforces this (defense-in-depth: the handler is the authoritative guard regardless // of caller). Either result = await mediator.Send(request.ToCommand(id), cancellationToken); return result.ToDeletedResult(); } [HttpPost("/api/v1/playlists/preview", Name = "PreviewPlaylist")] [Tags("Playlists")] [EndpointSummary("Preview the playout of a draft playlist")] [EndpointDescription("Builds a preview playout from the posted draft playlist items (no persistence).")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Preview( [Required][FromBody] ReplacePlaylistRequest request, CancellationToken cancellationToken) { // The preview path does not run the handler's CollectionTypesMustBeValid check // (PreviewPlaylistPlayoutHandler is shared with Blazor and left unchanged), so // validate the draft at the controller boundary to avoid a 500 in the playout // builder on an item missing the id required for its collection type. List invalidItems = request.ItemsMissingRequiredId(); if (invalidItems.Count > 0) { return BaseError .New($"Invalid playlist item(s) at index(es): {string.Join(", ", invalidItems)}") .ToErrorResult(); } Either> result = await mediator.Send(new PreviewPlaylistPlayout(request.ToReplaceCommand()), cancellationToken); return result.Match( Left: error => error.ToErrorResult(), Right: items => (IActionResult)new OkObjectResult(items.Map(ProjectToPreviewResponse).ToList())); } private static PlaylistGroupResponseModel ProjectToGroupResponse(PlaylistGroupViewModel vm) => new(vm.Id, vm.Name, vm.PlaylistCount, vm.IsSystem); private static PlaylistResponseModel ProjectToPlaylistResponse(PlaylistViewModel vm) => new(vm.Id, vm.PlaylistGroupId, vm.Name, vm.IsSystem); private static PlaylistItemResponseModel ProjectToItemResponse(PlaylistItemViewModel vm) => new( vm.Id, vm.Index, vm.CollectionType, vm.Collection?.Id, vm.Collection?.Name, vm.MultiCollection?.Id, vm.MultiCollection?.Name, vm.SmartCollection?.Id, vm.SmartCollection?.Name, vm.MediaItem?.MediaItemId, vm.MediaItem?.Name, vm.PlaybackOrder, vm.Count, vm.PlayAll, vm.IncludeInProgramGuide); private static PlaylistPreviewItemResponseModel ProjectToPreviewResponse(PlayoutItemPreviewViewModel vm) => new( vm.Title, vm.Start.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture), vm.Finish.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture), vm.Duration); }