using System.ComponentModel.DataAnnotations; using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Channels; using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Templates; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Scheduling; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] public class ChannelController( ChannelWriter workerChannel, IMediator mediator, IEntityLocker entityLocker) { [HttpGet("/api/channels")] [EndpointGroupName("general")] public async Task> GetAll() => await mediator.Send(new GetAllChannelsForApi()); [HttpGet("/api/channels/state")] [Tags("Channels")] [EndpointSummary("Get channel runtime state")] [EndpointGroupName("general")] public async Task> GetState(CancellationToken cancellationToken) => await mediator.Send(new GetChannelStatesForApi(DateTime.UtcNow), cancellationToken); [HttpGet("/api/guide")] [Tags("Channels")] [EndpointSummary("Get the JSON channel guide (EPG)")] [EndpointDescription( "Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now, " + "and end defaults to start plus the configured XmltvDaysToBuild window.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ChannelGuideResponseModel), StatusCodes.Status200OK)] public async Task GetGuide( [FromQuery] DateTimeOffset? start, [FromQuery] DateTimeOffset? end, CancellationToken cancellationToken) => await mediator.Send(new GetChannelGuideData(start, end), cancellationToken); [HttpGet("/api/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")] [Tags("Channels")] [EndpointSummary("Get available music video credits template names")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetMusicVideoCreditsTemplates(CancellationToken cancellationToken) => await mediator.Send(new GetMusicVideoCreditTemplates(), cancellationToken); [HttpGet("/api/channels/stream-selectors", Name = "GetChannelStreamSelectors")] [Tags("Channels")] [EndpointSummary("Get available channel stream selector names")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetStreamSelectors(CancellationToken cancellationToken) => await mediator.Send(new GetChannelStreamSelectors(), cancellationToken); [HttpGet("/api/channels/{id:int}", Name = "GetChannelById")] [Tags("Channels")] [EndpointSummary("Get a channel by id")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetChannelById(id), cancellationToken); return result.ToGetResult(); } [HttpPost("/api/channels")] [Tags("Channels")] [EndpointSummary("Create a channel")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Create( [Required] [FromBody] CreateChannelRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async created => { Option channel = await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken); return channel.Match( Some: vm => (IActionResult)new CreatedResult($"/api/channels/{vm.Id}", vm), None: () => ApiResults.NotFoundProblem()); }); } [HttpPost("/api/channels/from-lineup", Name = "CreateChannelFromLineup")] [Tags("Channels")] [EndpointSummary("Create a channel from a library lineup")] [EndpointDescription( "Atomically creates the channel, program schedule, a classic playout, and (for multi-item lineups) a " + "generated system playlist. A single-item lineup produces one flood schedule item that references the " + "target directly (movie, show, season, artist, collection, smart/multi collection, rerun collection, or " + "playlist) and no generated playlist. A lineup with two or more items produces one generated system " + "playlist whose entries play in the given order (each entry played in full before the next) referenced by " + "one flood schedule item; only movies, shows, seasons, artists, collections, smart collections and multi " + "collections are allowed there (rerun collections and playlists are single-item only). playbackOrder sets " + "how items within each lineup entry are ordered. Template defaults are stamped at create time; advanced " + "overrides win.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(CreateChannelFromLineupResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task CreateFromLineup( [Required] [FromBody] CreateChannelFromLineupRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToCreatedResult(response => $"/api/channels/{response.ChannelId}", response => response); } [HttpPut("/api/channels/{id:int}")] [Tags("Channels")] [EndpointSummary("Update a channel")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Update( int id, [Required] [FromBody] UpdateChannelRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(id), cancellationToken); return result.ToUpdatedResult(); } [HttpDelete("/api/channels/{id:int}")] [Tags("Channels")] [EndpointSummary("Delete a channel")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Delete(int id, CancellationToken cancellationToken) { Either result = await mediator.Send(new DeleteChannel(id), cancellationToken); return result.ToDeletedResult(); } [HttpPost("/api/channels/bulk/renumber")] [Tags("Channels")] [EndpointSummary("Renumber channels")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task BulkRenumber( [Required] [FromBody] BulkRenumberChannelsRequest request, CancellationToken cancellationToken) { Option result = await mediator.Send(request.ToCommand(), cancellationToken); return result.Match( Some: error => error.ToErrorResult(), None: () => new NoContentResult()); } [HttpPost("/api/channels/bulk/group")] [Tags("Channels")] [EndpointSummary("Move channels to a group")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task BulkMoveToGroup( [Required] [FromBody] BulkMoveChannelsToGroupRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToDeletedResult(); } [HttpPost("/api/channels/bulk/delete")] [Tags("Channels")] [EndpointSummary("Delete channels")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task BulkDelete( [Required] [FromBody] BulkDeleteChannelsRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToDeletedResult(); } [HttpPost("/api/channels/{channelNumber}/playout/reset")] [Tags("Channels")] [EndpointSummary("Reset channel playout")] [EndpointDescription( "When mode is omitted, classic playouts use Refresh (rebuild while maintaining collection " + "progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. " + "Pass mode to force a specific PlayoutBuildMode.")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] public async Task ResetPlayout( string channelNumber, [FromQuery] PlayoutBuildMode? mode, CancellationToken cancellationToken) { Option maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken); foreach (int playoutId in maybePlayoutId) { // Mirror Blazor's EntityLocker gating: don't enqueue a rebuild while one is already in flight. if (entityLocker.IsPlayoutLocked(playoutId)) { return ApiResults.ConflictProblem( "Playout build in progress", "A build for this playout is currently in progress; try again once it completes."); } PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken); await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken); return new OkResult(); } return ApiResults.NotFoundProblem(); } // Match Blazor's Playouts.razor reset semantics: classic playouts refresh (preserve progress), // every other kind resets from scratch. private async Task DefaultResetMode(int playoutId, CancellationToken cancellationToken) { Option maybePlayout = await mediator.Send(new GetPlayoutById(playoutId), cancellationToken); return maybePlayout.Match( Some: vm => vm.ScheduleKind switch { PlayoutScheduleKind.Classic => PlayoutBuildMode.Refresh, _ => PlayoutBuildMode.Reset }, None: () => PlayoutBuildMode.Reset); } }