using System.ComponentModel; 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.Api.LibraryBrowse; 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/v1/channels")] [EndpointGroupName("general")] public async Task> GetAll() => await mediator.Send(new GetAllChannelsForApi()); [HttpGet("/api/v1/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/v1/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/v1/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/v1/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/v1/channels/{id:int}", Name = "GetChannelById")] [Tags("Channels")] [EndpointSummary("Get a channel by id")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ChannelDetailResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetChannelByIdForApi(id), cancellationToken); return result.ToGetResult(); } [HttpPost("/api/v1/channels")] [Tags("Channels")] [EndpointSummary("Create a channel")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ChannelDetailResponseModel), 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 GetChannelByIdForApi(created.ChannelId), cancellationToken); return channel.Match( Some: model => (IActionResult)new CreatedResult($"/api/v1/channels/{model.Id}", model), None: () => ApiResults.NotFoundProblem()); }); } [HttpPost("/api/v1/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/v1/channels/{response.ChannelId}", response => response); } [HttpPut("/api/v1/channels/{id:int}")] [Tags("Channels")] [EndpointSummary("Update a channel")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ChannelDetailResponseModel), 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 await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { // Re-project through the read-side query so the response carries the same // ChannelDetailResponseModel shape (raw editable ids the editor re-drafts) as GET. Option channel = await mediator.Send(new GetChannelByIdForApi(id), cancellationToken); return channel.Match( Some: model => (IActionResult)new OkObjectResult(model), None: () => ApiResults.NotFoundProblem()); }); } [HttpDelete("/api/v1/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/v1/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/v1/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/v1/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/v1/channels/auto-tune/preview", Name = "PreviewAutoTuneChannels")] [Tags("Channels")] [EndpointSummary("Preview auto-tuned channels")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task PreviewAutoTune( [Required][FromBody] PreviewAutoTuneChannelsRequest request, CancellationToken cancellationToken) { Either> result = await mediator.Send(request.ToCommand(), cancellationToken); return result.Match( Left: error => error.ToErrorResult(), Right: proposals => new OkObjectResult(proposals.Select(ProjectToResponseModel).ToList())); } [HttpPost("/api/v1/channels/auto-tune", Name = "CreateAutoTunedChannels")] [Tags("Channels")] [EndpointSummary("Create auto-tuned channels")] [EndpointDescription( "Bulk-creates the selected auto-tuned channels. Each channel is independent — one failure never " + "aborts the batch (see the per-channel Created/Skipped/Failed outcomes). Per channel, the optional " + "templateId, advanced overrides (same set as the manual Channel Builder; advanced values win over " + "the template) and an uploaded logo image override the batch defaults; an omitted field keeps the " + "batch template, the axis-derived playback order and the on-the-fly fallback logo.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(AutoTuneResultResponseModel), StatusCodes.Status200OK)] public async Task CreateAutoTuned( [Required][FromBody] CreateAutoTunedChannelsRequest request, CancellationToken cancellationToken) { AutoTuneResult result = await mediator.Send(request.ToCommand(), cancellationToken); return new OkObjectResult(ProjectToResponseModel(result)); } [HttpGet("/api/v1/channels/auto-tune/members", Name = "GetAutoTuneChannelMembers")] [Tags("Channels")] [EndpointSummary("List a proposed auto-tune channel's distinct content-source members")] [EndpointDescription( "Given an auto-tune axis and value, returns the distinct content sources (parent shows for the " + "TV axes, movies for the movie-genre axis) the server-generated SmartCollection query resolves " + "to, with a per-source item count. Read-only; the server owns query generation.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PagedLibraryBrowseItemsResponseModel), StatusCodes.Status200OK)] public async Task GetAutoTuneChannelMembers( [FromQuery] AutoTuneAxis axis, [FromQuery] string value, [FromQuery] [Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")] int pageNum, [FromQuery] [Description("Rows per page; capped at 200 for this endpoint. A value of 0 or less falls back to 100 rather than being clamped to 1. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")] int pageSize, CancellationToken cancellationToken) { pageNum = Math.Max(0, pageNum); pageSize = pageSize <= 0 ? 100 : Math.Min(pageSize, 200); return await mediator.Send( new GetAutoTuneChannelMembers(axis, value, pageNum, pageSize), cancellationToken); } private static AutoTuneProposalResponseModel ProjectToResponseModel(AutoTuneProposal p) => new(p.Axis.ToString(), p.Value, p.Name, p.Number, p.ItemCount, p.AlreadyExists); private static AutoTuneResultResponseModel ProjectToResponseModel(AutoTuneResult r) => new( r.Results.Select(o => new AutoTuneChannelResultModel( o.Name, o.Status.ToString(), o.ChannelId, o.Reason)).ToList(), r.CreatedCount, r.SkippedCount, r.FailedCount); [HttpPost("/api/v1/channels/{id:int}/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.Status202Accepted)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] public async Task ResetPlayout( int id, [FromQuery] PlayoutBuildMode? mode, CancellationToken cancellationToken) { Option maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelId(id), 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 AcceptedResult(); } 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); } }