using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.Playouts; using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Application.Scheduling; using ErsatzTV.Application.Troubleshooting; using ErsatzTV.Application.Troubleshooting.Queries; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Playouts; using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) : ControllerBase { private const int MaxPageSize = 100; // Blazor disables per-playout Reset/Erase/Delete/Edit while a build is in flight // (EntityLocker.IsPlayoutLocked); the API mirrors that invariant by rejecting any // id-keyed mutation with 409 while the build lock is held. See docs/decisions.md 2026-07-10. private const string BuildInProgressTitle = "Playout build in progress"; private const string BuildInProgressDetail = "A build for this playout is currently in progress; try again once it completes."; private static IActionResult PlayoutLockedProblem() => ApiResults.ConflictProblem(BuildInProgressTitle, BuildInProgressDetail); [HttpGet("/api/playouts", Name = "GetPlayouts")] [Tags("Playouts")] [EndpointSummary("List playouts")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PagedPlayoutsResponseModel), StatusCodes.Status200OK)] public async Task GetAll( [FromQuery] string query = "", [FromQuery] int pageNum = 0, [FromQuery] int pageSize = 100, CancellationToken cancellationToken = default) { PagedPlayoutsViewModel result = await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken); return new PagedPlayoutsResponseModel( result.TotalCount, result.Page.Map(vm => ToListItemResponse(vm, entityLocker.IsPlayoutLocked(vm.PlayoutId))).ToList()); } [HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")] [Tags("Playouts")] [EndpointSummary("Count playouts with a failed build")] [EndpointGroupName("general")] [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] public async Task GetWarningsCount(CancellationToken cancellationToken) => await mediator.Send(new GetPlayoutWarningsCount(), cancellationToken); [HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")] [Tags("Playouts")] [EndpointSummary("Get a playout by id")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetPlayoutById(id), cancellationToken); return result.Map(vm => ToResponse(vm, entityLocker.IsPlayoutLocked(id))).ToGetResult(); } [HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")] [Tags("Playouts")] [EndpointSummary("Get upcoming items (and unscheduled gaps) for a playout")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PagedPlayoutItemsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetItems( int id, [FromQuery] bool showFiller = false, [FromQuery] int pageNum = 0, [FromQuery] int pageSize = 100, CancellationToken cancellationToken = default) { Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); if (maybePlayout.IsNone) { return ApiResults.NotFoundProblem(); } PagedPlayoutItemsViewModel result = await mediator.Send( new GetFuturePlayoutItemsById(id, showFiller, pageNum, pageSize), cancellationToken); return new OkObjectResult( new PagedPlayoutItemsResponseModel( result.TotalCount, result.Page.Map(ToItemResponse).ToList())); } [HttpPost("/api/playouts")] [Tags("Playouts")] [EndpointSummary("Create a playout")] [EndpointDescription( "Creates a playout of any kind (Classic, Block, Sequential, Scripted, or ExternalJson) for a channel. " + "Classic requires ProgramScheduleId; Sequential/Scripted/ExternalJson require ScheduleFile; Block requires " + "neither. A channel may only have one playout.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Create( [Required] [FromBody] CreatePlayoutRequest request, CancellationToken cancellationToken) { Either commandOrError = request.ToCommand(); return await commandOrError.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async command => { Either result = await mediator.Send(command, cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async created => { Option playout = await mediator.Send(new GetPlayoutById(created.PlayoutId), cancellationToken); return playout.Match( Some: vm => (IActionResult)new CreatedResult( $"/api/playouts/{vm.PlayoutId}", ToResponse(vm, entityLocker.IsPlayoutLocked(vm.PlayoutId))), None: () => ApiResults.NotFoundProblem()); }); }); } [HttpPut("/api/playouts/{id:int}")] [Tags("Playouts")] [EndpointSummary("Update playout scheduling details")] [EndpointDescription( "DailyRebuildTime is always applied; omit it (null) to clear the daily reset. ScheduleFile is only valid " + "for Sequential, Scripted, and ExternalJson playouts; omit it (null) to leave it unchanged.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Update( int id, [Required] [FromBody] UpdatePlayoutDetailsRequest request, CancellationToken cancellationToken) { if (entityLocker.IsPlayoutLocked(id)) { return PlayoutLockedProblem(); } Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); if (maybePlayout.IsNone) { return ApiResults.NotFoundProblem(); } var hasScheduleFile = !string.IsNullOrWhiteSpace(request.ScheduleFile); foreach (PlayoutNameViewModel playout in maybePlayout) { if (hasScheduleFile && playout.ScheduleKind is not (PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted or PlayoutScheduleKind.ExternalJson)) { BaseError error = BaseError.New("[ScheduleFile] is only valid for Sequential, Scripted, or ExternalJson playouts"); return error.ToErrorResult(); } } // the schedule-file update is the only step that can fail after the pre-checks, // so it goes first — a rejected file must not leave DailyRebuildTime applied if (hasScheduleFile) { foreach (PlayoutNameViewModel playout in maybePlayout) { Either scheduleFileResult = await UpdateScheduleFile(playout, request.ScheduleFile, cancellationToken); foreach (BaseError error in scheduleFileResult.LeftToSeq()) { return error.ToErrorResult(); } } } Option dailyRebuildTime = request.DailyRebuildTime is { } t ? Some(t) : Option.None; Either result = await mediator.Send(new UpdatePlayout(id, dailyRebuildTime), cancellationToken); return result.Match( Left: error => error.ToErrorResult(), Right: playout => (IActionResult)new OkObjectResult( ToResponse(playout, entityLocker.IsPlayoutLocked(id)))); } private async Task> UpdateScheduleFile( PlayoutNameViewModel playout, string scheduleFile, CancellationToken cancellationToken) => playout.ScheduleKind switch { PlayoutScheduleKind.Sequential => await mediator.Send( new UpdateSequentialPlayout(playout.PlayoutId, scheduleFile), cancellationToken), PlayoutScheduleKind.Scripted => await mediator.Send( new UpdateScriptedPlayout(playout.PlayoutId, scheduleFile), cancellationToken), PlayoutScheduleKind.ExternalJson => await mediator.Send( new UpdateExternalJsonPlayout(playout.PlayoutId, scheduleFile), cancellationToken), _ => BaseError.New("[ScheduleFile] is only valid for Sequential, Scripted, or ExternalJson playouts") }; [HttpPut("/api/playouts/{id:int}/deco")] [Tags("Playouts")] [EndpointSummary("Set (or clear) a playout's default deco")] [EndpointDescription("Assigns the default deco for a block playout. Send a null decoId to clear it.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task UpdateDefaultDeco( int id, [Required] [FromBody] UpdateDefaultDecoRequest request, CancellationToken cancellationToken) { if (entityLocker.IsPlayoutLocked(id)) { return PlayoutLockedProblem(); } Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); if (maybePlayout.IsNone) { return ApiResults.NotFoundProblem(); } if (request.DecoId is { } decoId) { Option maybeDeco = await mediator.Send(new GetDecoById(decoId), cancellationToken); if (maybeDeco.IsNone) { return BaseError.New("Deco does not exist").ToErrorResult(); } } Option result = await mediator.Send(request.ToCommand(id), cancellationToken); if (result.IsSome) { foreach (BaseError error in result) { return error.ToErrorResult(); } } Option refreshed = await mediator.Send(new GetPlayoutById(id), cancellationToken); return refreshed.Match( Some: vm => (IActionResult)new OkObjectResult(ToResponse(vm, entityLocker.IsPlayoutLocked(id))), None: () => ApiResults.NotFoundProblem()); } [HttpGet("/api/playouts/{id:int}/alternate-schedules", Name = "GetPlayoutAlternateSchedules")] [Tags("Playouts")] [EndpointSummary("Get a classic playout's alternate schedules")] [EndpointDescription( "Lists a Classic playout's alternate schedules in priority order (first = highest priority). The last " + "entry is the catch-all default, and its schedule is the playout's default schedule; if no explicit " + "catch-all exists the query synthesizes one from the playout's default schedule. Only valid for Classic " + "playouts; other kinds return 422.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task GetAlternateSchedules(int id, CancellationToken cancellationToken) { Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); if (maybePlayout.IsNone) { return ApiResults.NotFoundProblem(); } foreach (PlayoutNameViewModel playout in maybePlayout) { if (playout.ScheduleKind is not PlayoutScheduleKind.Classic) { return BaseError.New("[AlternateSchedules] are only valid for Classic playouts").ToErrorResult(); } } // The GET returns children, so read the playout's version for the concurrency ETag (issue #253). ConcurrencyHeaders.SetETag(Response, maybePlayout.Map(p => p.Version).IfNone(0)); List items = await mediator.Send(new GetPlayoutAlternateSchedules(id), cancellationToken); return new OkObjectResult(items.OrderBy(i => i.Index).Select(ToResponse).ToList()); } [HttpPut("/api/playouts/{id:int}/alternate-schedules")] [Tags("Playouts")] [EndpointSummary("Replace a classic playout's alternate schedules")] [EndpointDescription( "Replaces a Classic playout's alternate schedules. Items are ordered by priority: the first item is the " + "highest priority and the last item is the catch-all default whose schedule becomes the playout's default " + "schedule. Index is assigned from array order (the request body has no Index field). The list must contain " + "at least one item, and every ProgramScheduleId must exist. Only valid for Classic playouts.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task ReplaceAlternateSchedules( int id, [Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request, CancellationToken cancellationToken) { IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request); if (ifMatch.Kind is IfMatchKind.Malformed) { return ConcurrencyHeaders.MalformedIfMatchProblem(); } if (entityLocker.IsPlayoutLocked(id)) { return PlayoutLockedProblem(); } Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); if (maybePlayout.IsNone) { return ApiResults.NotFoundProblem(); } foreach (PlayoutNameViewModel playout in maybePlayout) { if (playout.ScheduleKind is not PlayoutScheduleKind.Classic) { return BaseError.New("[AlternateSchedules] are only valid for Classic playouts").ToErrorResult(); } } List items = request.Items ?? []; if (items.Count == 0) { return BaseError.New("[Items] must contain at least one alternate schedule").ToErrorResult(); } foreach (BaseError error in ValidateDateRanges(items.Select(ToDateRangeCheck))) { return error.ToErrorResult(); } List schedules = await mediator.Send(new GetAllProgramSchedules(), cancellationToken); var scheduleIds = schedules.Select(s => s.Id).ToHashSet(); var missingScheduleIds = items.Select(i => i.ProgramScheduleId).Distinct() .Where(scheduleId => !scheduleIds.Contains(scheduleId)).ToList(); if (missingScheduleIds.Count > 0) { return BaseError.New($"[ProgramScheduleId] {missingScheduleIds[0]} does not exist").ToErrorResult(); } Either result = await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { // Emit the new ETag (post-bump) so a same-tab second save doesn't 412 against its own write (#253). Option refreshedPlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); ConcurrencyHeaders.SetETag(Response, refreshedPlayout.Map(p => p.Version).IfNone(0)); List refreshed = await mediator.Send(new GetPlayoutAlternateSchedules(id), cancellationToken); return (IActionResult)new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList()); }); } [HttpGet("/api/playouts/{id:int}/templates", Name = "GetPlayoutTemplates")] [Tags("Playouts")] [EndpointSummary("Get a block playout's templates")] [EndpointDescription( "Lists a Block playout's templates in priority order (first = highest priority). Each template optionally " + "carries a deco template. Only valid for Block playouts; other kinds return 422.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task GetTemplates(int id, CancellationToken cancellationToken) { Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); if (maybePlayout.IsNone) { return ApiResults.NotFoundProblem(); } foreach (PlayoutNameViewModel playout in maybePlayout) { if (playout.ScheduleKind is not PlayoutScheduleKind.Block) { return BaseError.New("[Templates] are only valid for Block playouts").ToErrorResult(); } } // The GET returns children, so read the playout's version for the concurrency ETag (issue #253). ConcurrencyHeaders.SetETag(Response, maybePlayout.Map(p => p.Version).IfNone(0)); List items = await mediator.Send(new GetPlayoutTemplates(id), cancellationToken); return new OkObjectResult(items.OrderBy(i => i.Index).Select(ToResponse).ToList()); } [HttpPut("/api/playouts/{id:int}/templates")] [Tags("Playouts")] [EndpointSummary("Replace a block playout's templates")] [EndpointDescription( "Replaces a Block playout's templates. Items are ordered by priority (first = highest priority); Index is " + "assigned from array order (the request body has no Index field). Every TemplateId must exist, and any " + "supplied DecoTemplateId must exist. An empty list clears all templates. Only valid for Block playouts.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task ReplaceTemplates( int id, [Required] [FromBody] ReplacePlayoutTemplatesRequest request, CancellationToken cancellationToken) { IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request); if (ifMatch.Kind is IfMatchKind.Malformed) { return ConcurrencyHeaders.MalformedIfMatchProblem(); } if (entityLocker.IsPlayoutLocked(id)) { return PlayoutLockedProblem(); } Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); if (maybePlayout.IsNone) { return ApiResults.NotFoundProblem(); } foreach (PlayoutNameViewModel playout in maybePlayout) { if (playout.ScheduleKind is not PlayoutScheduleKind.Block) { return BaseError.New("[Templates] are only valid for Block playouts").ToErrorResult(); } } List items = request.Items ?? []; foreach (BaseError error in ValidateDateRanges(items.Select(ToDateRangeCheck))) { return error.ToErrorResult(); } List templates = await mediator.Send(new GetAllTemplates(), cancellationToken); var templateIds = templates.Select(t => t.Id).ToHashSet(); var missingTemplateIds = items.Select(i => i.TemplateId).Distinct() .Where(templateId => !templateIds.Contains(templateId)).ToList(); if (missingTemplateIds.Count > 0) { return BaseError.New($"[TemplateId] {missingTemplateIds[0]} does not exist").ToErrorResult(); } var decoTemplateIds = items.Where(i => i.DecoTemplateId.HasValue) .Select(i => i.DecoTemplateId!.Value).Distinct().ToList(); foreach (int decoTemplateId in decoTemplateIds) { Option maybeDecoTemplate = await mediator.Send(new GetDecoTemplateById(decoTemplateId), cancellationToken); if (maybeDecoTemplate.IsNone) { return BaseError.New($"[DecoTemplateId] {decoTemplateId} does not exist").ToErrorResult(); } } Option result = await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken); foreach (BaseError error in result) { return error.ToErrorResult(); } // Emit the new ETag (post-bump) so a same-tab second save doesn't 412 against its own write (#253). Option refreshedPlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); ConcurrencyHeaders.SetETag(Response, refreshedPlayout.Map(p => p.Version).IfNone(0)); List refreshed = await mediator.Send(new GetPlayoutTemplates(id), cancellationToken); return new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList()); } [HttpGet("/api/playouts/{id:int}/blocks", Name = "GetPlayoutBlocks")] [Tags("Playouts")] [EndpointSummary("Get the blocks scheduled by a block playout")] [EndpointDescription( "Lists the distinct blocks reachable through a Block playout's templates, ordered by group then name. " + "A playout with no templates (including non-Block playouts) returns an empty list.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetBlocks(int id, CancellationToken cancellationToken) { Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); if (maybePlayout.IsNone) { return ApiResults.NotFoundProblem(); } List blocks = await mediator.Send(new GetAllBlocksForPlayout(id), cancellationToken); return new OkObjectResult(blocks.Map(ToBlockResponse).ToList()); } [HttpGet("/api/playouts/{id:int}/blocks/{blockId:int}/history", Name = "GetPlayoutBlockHistory")] [Tags("Playouts")] [EndpointSummary("Get a block's playout history")] [EndpointDescription( "Returns the paged scheduling history for a single block within a block playout, oldest first. Each row's " + "Key and Details carry raw JSON; decode a row via GET /api/playouts/history/{id}.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PagedPlayoutHistoryResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetBlockHistory( int id, int blockId, [FromQuery] int pageNum = 0, [FromQuery] int pageSize = 100, CancellationToken cancellationToken = default) { Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); if (maybePlayout.IsNone) { return ApiResults.NotFoundProblem(); } int clampedPageNum = Math.Max(0, pageNum); int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize); PagedPlayoutHistoryViewModel result = await mediator.Send( new GetBlockPlayoutHistory(id, blockId, clampedPageNum, clampedPageSize), cancellationToken); return new OkObjectResult( new PagedPlayoutHistoryResponseModel( result.TotalCount, result.Page.Map(ToHistoryResponse).ToList())); } [HttpGet("/api/playouts/history/{id:int}", Name = "GetPlayoutHistoryDetails")] [Tags("Playouts")] [EndpointSummary("Decode a playout history row")] [EndpointDescription( "Decodes a single playout history row (by its id) into its playback order, collection, and media-item " + "details. Returns 422 if the row's stored Key/Details JSON cannot be decoded.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PlayoutHistoryDetailsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task GetHistoryDetails(int id, CancellationToken cancellationToken) { Either result = await mediator.Send(new GetPlayoutHistoryDetails(id), cancellationToken); return result.Match( Left: error => error.ToErrorResult(), Right: vm => (IActionResult)new OkObjectResult(ToDetailsResponse(vm))); } [HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")] [Tags("Playouts")] [EndpointSummary("Reset all playouts")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ResetAllPlayoutsResponseModel), StatusCodes.Status202Accepted)] // No 409 lock guard here (unlike the id-keyed mutations): ResetAllPlayoutsHandler already // skips any playout whose build lock is held, matching Blazor. It is a fire-and-forget // bulk enqueue, so it always accepts — the 202 body reports which playouts were queued and // which were skipped (locked, or an unsupported ExternalJson/None kind). See docs/decisions.md 2026-07-10. public async Task ResetAll(CancellationToken cancellationToken) { ResetAllPlayoutsResult result = await mediator.Send(new ResetAllPlayouts(), cancellationToken); var body = new ResetAllPlayoutsResponseModel( result.QueuedPlayoutIds, result.SkippedLocked, result.SkippedUnsupported); return new AcceptedResult((string)null, body); } [HttpPost("/api/playouts/{id:int}/erase-items", Name = "ErasePlayoutItems")] [Tags("Playouts")] [EndpointSummary("Erase a playout's items")] [EndpointDescription( "Deletes the built items (plus gaps and build status) for a Block, Sequential, or Scripted playout, " + "preserving history that precedes the currently-airing item. Only valid for those kinds; other kinds " + "return 422.")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task EraseItems(int id, CancellationToken cancellationToken) { if (entityLocker.IsPlayoutLocked(id)) { return PlayoutLockedProblem(); } Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); if (maybePlayout.IsNone) { return ApiResults.NotFoundProblem(); } foreach (PlayoutNameViewModel playout in maybePlayout) { if (playout.ScheduleKind is not (PlayoutScheduleKind.Block or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted)) { return BaseError.New("[EraseItems] is only valid for Block, Sequential, or Scripted playouts") .ToErrorResult(); } } await mediator.Send(new ErasePlayoutItems(id), cancellationToken); return NoContent(); } [HttpPost("/api/playouts/{id:int}/erase-items-and-history", Name = "ErasePlayoutItemsAndHistory")] [Tags("Playouts")] [EndpointSummary("Erase a playout's items and history")] [EndpointDescription( "Deletes all built items, history, anchors, and build status for a Classic, Block, Sequential, or " + "Scripted playout, and reseeds it. Only valid for those kinds; other kinds return 422.")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task EraseItemsAndHistory(int id, CancellationToken cancellationToken) { if (entityLocker.IsPlayoutLocked(id)) { return PlayoutLockedProblem(); } Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); if (maybePlayout.IsNone) { return ApiResults.NotFoundProblem(); } foreach (PlayoutNameViewModel playout in maybePlayout) { if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted)) { return BaseError.New( "[EraseItemsAndHistory] is only valid for Classic, Block, Sequential, or Scripted playouts") .ToErrorResult(); } } await mediator.Send(new ErasePlayoutHistory(id), cancellationToken); return NoContent(); } [HttpGet("/api/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")] [Tags("Playouts")] [EndpointSummary("Decode a playout item's scheduling context")] [EndpointDescription( "Decodes the stored scheduling context for a single playout item (by its row id) into readable, enriched " + "JSON. Returns 404 when the item is missing or has no scheduling context.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PlayoutItemSchedulingContextResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetItemSchedulingContext(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetPlayoutItemSchedulingContext(id), cancellationToken); return result.Map(context => new PlayoutItemSchedulingContextResponseModel(context)).ToGetResult(); } [HttpDelete("/api/playouts/{id:int}")] [Tags("Playouts")] [EndpointSummary("Delete a playout")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Delete(int id, CancellationToken cancellationToken) { if (entityLocker.IsPlayoutLocked(id)) { return PlayoutLockedProblem(); } Either result = await mediator.Send(new DeletePlayout(id), cancellationToken); return result.ToDeletedResult(); } // Guards against AlternateScheduleSelector crashing on out-of-range dates: it constructs // `new DateTime(year, StartMonth, StartDay)` and only recovers from an overflowing *day* // (rolling to the 1st of the next month); an out-of-range *month* throws again from inside // that recovery path and is never caught. Only checked when LimitToDateRange is set, since // the fields are ignored otherwise. private static (bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay) ToDateRangeCheck( PlayoutAlternateScheduleItemRequest item) => (item.LimitToDateRange, item.StartMonth, item.StartDay, item.EndMonth, item.EndDay); private static (bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay) ToDateRangeCheck( PlayoutTemplateItemRequest item) => (item.LimitToDateRange, item.StartMonth, item.StartDay, item.EndMonth, item.EndDay); private static Option ValidateDateRanges( IEnumerable<(bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay)> items) { foreach ((bool limitToDateRange, int startMonth, int startDay, int endMonth, int endDay) in items) { if (!limitToDateRange) { continue; } if (startMonth is < 1 or > 12) { return Some(BaseError.New($"[StartMonth] {startMonth} must be between 1 and 12")); } if (endMonth is < 1 or > 12) { return Some(BaseError.New($"[EndMonth] {endMonth} must be between 1 and 12")); } if (startDay is < 1 or > 31) { return Some(BaseError.New($"[StartDay] {startDay} must be between 1 and 31")); } if (endDay is < 1 or > 31) { return Some(BaseError.New($"[EndDay] {endDay} must be between 1 and 31")); } } return Option.None; } private static BlockResponseModel ToBlockResponse(BlockViewModel vm) => new(vm.Id, vm.GroupId, vm.GroupName, vm.Name, vm.Minutes, vm.StopScheduling); private static PlayoutHistoryResponseModel ToHistoryResponse(PlayoutHistoryViewModel vm) => new(vm.Id, vm.When, vm.Finish, vm.Key, vm.Details); private static PlayoutHistoryDetailsResponseModel ToDetailsResponse(PlayoutHistoryDetailsViewModel vm) => new(vm.PlaybackOrder, vm.CollectionType, vm.Name, vm.MediaItemType, vm.MediaItemTitle); private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked) => PlayoutResponseModel.From( vm.PlayoutId, vm.ScheduleKind, vm.ChannelName, vm.ChannelNumber, vm.PlayoutMode, vm.ScheduleName, vm.ScheduleFile, vm.DbDailyRebuildTime, ToBuildStatus(vm.BuildStatus), vm.DecoId, vm.DecoName, isLocked); private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) => new( vm.Id, vm.Index, vm.ProgramScheduleId, vm.DaysOfWeek, vm.DaysOfMonth, vm.MonthsOfYear, vm.LimitToDateRange, vm.StartMonth, vm.StartDay, vm.StartYear, vm.EndMonth, vm.EndDay, vm.EndYear); private static PlayoutTemplateResponseModel ToResponse(PlayoutTemplateViewModel vm) => new( vm.Id, vm.Index, vm.Template.Id, vm.Template.Name, vm.Template.GroupName, vm.DecoTemplate?.Id, vm.DecoTemplate?.Name, vm.DecoTemplate?.GroupName, vm.DaysOfWeek, vm.DaysOfMonth, vm.MonthsOfYear, vm.LimitToDateRange, vm.StartMonth, vm.StartDay, vm.StartYear, vm.EndMonth, vm.EndDay, vm.EndYear); private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm, bool isLocked) => new( vm.PlayoutId, vm.ChannelNumber, vm.ChannelName, vm.ScheduleKind, vm.ScheduleName, vm.DbDailyRebuildTime, ToBuildStatus(vm.BuildStatus), vm.PlayoutMode, isLocked); private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) => buildStatus is null ? null : new PlayoutBuildStatusResponseModel( buildStatus.LastBuild, buildStatus.Success, buildStatus.Message); private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) => new( vm.Id, vm.Title, vm.Start, vm.Finish, vm.Duration, vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null), !string.IsNullOrWhiteSpace(vm.SchedulingContext)); }