Files
ersatztv/ErsatzTV/Controllers/Api/ScriptedScheduleController.cs
T
timothyandClaude Opus 5.5 324dbdbca0
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Failing after 10s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
review-verdict/h10 Awaiting review verdict for 324dbdb
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m52s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 12m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m48s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
fix(913): scripted playout callbacks authenticate with a build-scoped token
A scripted playout's program calls back into ScriptedScheduleController,
which is under the fail-closed global ApiAuthorizationFilter, but was
handed no credential. Reproduced 2026-10-29 on a live local instance at
origin/main e919b925f (scripts/e2e-local.sh, port 8510): the program's
GET .../context and POST .../add_collection both returned 401, and the
playout's buildStatus still read success=true with no items, because a
program that exits 0 after the failure (entrypoint.py catches
ApiException and does) looks like a clean build.

Fix: ScriptedPlayoutBuilderService.StartSession mints a 256-bit token per
build, independent of the build id; ScriptedPlayoutBuilder passes it in
ETV_SCRIPTED_BUILD_TOKEN (environment, not argv: argv is logged at INF
and a fourth positional would shift the user's own arguments);
ApiAuthorizationFilter accepts it as X-Scripted-Build-Token on endpoints
carrying [AcceptsScriptedBuildToken] (ScriptedScheduleController only),
for the build named by the buildId route value, until EndSession.
entrypoint.py sends it as a default header. [SkipApiAuthorization] on the
controller was rejected: it widens the auth-exempt set by a mutating
controller whose only remaining gate would be a logged GUID, and the
Startup host check does not make the endpoint localhost-only.

Tests: ScriptedScheduleAuthorizationTests drives the real filter over the
endpoint metadata MVC builds from the production Startup for all 28
actions; ScriptedBuildTokenTests covers minting/validation and the
launch environment. Clause mutations, each rebuilt and run 2026-10-29:
  - drop [AcceptsScriptedBuildToken] from the controller -> red:
    Running_Builds_Own_Token_Is_Accepted..., Scripted_Controller_Is_Gated...
  - disarm the filter's token clause (no return) -> red: Running_Builds_Own_Token...
  - delete the filter's marker check -> red: Token_Opens_Nothing_Outside...
  - add [SkipApiAuthorization] to the controller -> red: No_Credential...,
    Scripted_Controller_Is_Gated..., Token_Of_Another..., Token_Is_A_401_Once...,
    Wrong_Or_Empty_Token (x2), ApiControllerSecurityTests.Only_Scanner_And_Auth...
  - token comparison always true -> red: Token_Of_Another..., Wrong_Or_Empty("not-the-token")
  - drop WithEnvironmentVariables from CreateCommand -> red: Launch_Hands_The_Token...
  - RouteValueName "buildId" -> "id" -> red: Token_Route_Value_Is_The_Controllers...

Live E2E on the fixed build (port 8510, fresh config): the real
entrypoint.py with the generated etv_client copied from the
ersatztv-test image built 3 playout items from a scanned collection;
as the program, own token -> 200 on GET context and POST add_collection;
no header, wrong token, another build's id, and the token on
GET /api/v1/channels and POST /api/v1/collections -> 401; the token
after the build ended -> 401; token absent from the server log.

Docs: new record api.scripted-build-token; testing.scripted-engine-in-
process-net's 401 clause now records the outcome; api-conventions §9 and
testing.md updated; catalog regenerated. OpenAPI regenerated: no drift.

Fixes #913

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
2026-10-30 00:34:50 +01:00

668 lines
22 KiB
C#

using ErsatzTV.Core.Api.ScriptedPlayout;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Filters;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
/// <summary>
/// The callback surface a scripted playout's program drives during a build. Under the global API
/// authorization filter like every other controller — not <c>[SkipApiAuthorization]</c> — and additionally
/// accepts the build-scoped token <c>ScriptedPlayoutBuilder</c> hands the program, so the program needs
/// neither the machine key nor a session (ersatztv#913).
/// </summary>
[ApiController]
[AcceptsScriptedBuildToken]
[EndpointGroupName("scripted-schedule")]
[Route("/api/v1/scripted/playout/build/{buildId:guid}")]
public class ScriptedScheduleController(IScriptedPlayoutBuilderService scriptedPlayoutBuilderService) : ControllerBase
{
[HttpGet("context", Name = "GetContext")]
[Tags("Scripted Metadata")]
[EndpointSummary("Get the current context")]
public ActionResult<PlayoutContext> GetContext([FromRoute] Guid buildId)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
return GetContextInternal(engine);
}
[HttpPost("add_collection", Name = "AddCollection")]
[Tags("Scripted Content")]
[EndpointSummary("Add a collection")]
public async Task<IActionResult> AddCollection(
[FromRoute]
Guid buildId,
[FromBody]
ContentCollection request,
CancellationToken cancellationToken)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
if (!Enum.TryParse(request.Order, ignoreCase: true, out PlaybackOrder playbackOrder))
{
return BadRequest("Invalid playback order.");
}
await engine.AddCollection(request.Key, request.Collection, playbackOrder, cancellationToken);
return Ok();
}
[HttpPost("add_marathon", Name = "AddMarathon")]
[Tags("Scripted Content")]
[EndpointSummary("Add a marathon")]
public async Task<IActionResult> AddMarathon([FromRoute] Guid buildId, [FromBody] ContentMarathon request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
if (!Enum.TryParse(request.ItemOrder, ignoreCase: true, out PlaybackOrder itemPlaybackOrder))
{
return BadRequest("Invalid item playback order.");
}
await engine.AddMarathon(
request.Key,
request.Guids ?? [],
request.Searches ?? [],
request.GroupBy,
request.ShuffleGroups,
itemPlaybackOrder,
request.PlayAllItems);
return Ok();
}
[HttpPost("add_multi_collection", Name = "AddMultiCollection")]
[Tags("Scripted Content")]
[EndpointSummary("Add a multi-collection")]
public async Task<IActionResult> AddMultiCollection(
[FromRoute]
Guid buildId,
[FromBody]
ContentMultiCollection request,
CancellationToken cancellationToken)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
if (!Enum.TryParse(request.Order, ignoreCase: true, out PlaybackOrder playbackOrder))
{
return BadRequest("Invalid playback order.");
}
await engine.AddMultiCollection(request.Key, request.MultiCollection, playbackOrder, cancellationToken);
return Ok();
}
[HttpPost("add_playlist", Name = "AddPlaylist")]
[Tags("Scripted Content")]
[EndpointSummary("Add a playlist")]
public async Task<IActionResult> AddPlaylist(
[FromRoute]
Guid buildId,
[FromBody]
ContentPlaylist request,
CancellationToken cancellationToken)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
await engine.AddPlaylist(request.Key, request.Playlist, request.PlaylistGroup, cancellationToken);
return Ok();
}
[HttpPost("create_playlist", Name = "CreatePlaylist")]
[Tags("Scripted Content")]
[EndpointSummary("Create a playlist")]
public async Task<IActionResult> CreatePlaylist(
[FromRoute]
Guid buildId,
[FromBody]
ContentCreatePlaylist request,
CancellationToken cancellationToken)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
await engine.CreatePlaylist(
request.Key,
request.Items.ToDictionary(i => i.Content, i => i.Count),
cancellationToken);
return Ok();
}
[HttpPost("add_search", Name = "AddSearch")]
[Tags("Scripted Content")]
[EndpointSummary("Add a search query")]
public async Task<IActionResult> AddSearch(
[FromRoute]
Guid buildId,
[FromBody]
ContentSearch request,
CancellationToken cancellationToken)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
if (!Enum.TryParse(request.Order, ignoreCase: true, out PlaybackOrder playbackOrder))
{
return BadRequest("Invalid playback order.");
}
await engine.AddSearch(request.Key, request.Query, playbackOrder, cancellationToken);
return Ok();
}
[HttpPost("add_smart_collection", Name = "AddSmartCollection")]
[Tags("Scripted Content")]
[EndpointSummary("Add a smart collection")]
public async Task<IActionResult> AddSmartCollection(
[FromRoute]
Guid buildId,
[FromBody]
ContentSmartCollection request,
CancellationToken cancellationToken)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
if (!Enum.TryParse(request.Order, ignoreCase: true, out PlaybackOrder playbackOrder))
{
return BadRequest("Invalid playback order.");
}
await engine.AddSmartCollection(request.Key, request.SmartCollection, playbackOrder, cancellationToken);
return Ok();
}
[HttpPost("add_show", Name = "AddShow")]
[Tags("Scripted Content")]
[EndpointSummary("Add a show")]
public async Task<IActionResult> AddShow(
[FromRoute]
Guid buildId,
[FromBody]
ContentShow request,
CancellationToken cancellationToken)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
if (!Enum.TryParse(request.Order, ignoreCase: true, out PlaybackOrder playbackOrder))
{
return BadRequest("Invalid playback order.");
}
await engine.AddShow(request.Key, request.Guids, playbackOrder);
return Ok();
}
[HttpPost("add_all", Name = "AddAll")]
[Tags("Scripted Scheduling")]
[EndpointSummary("Add all content")]
public ActionResult<PlayoutContext> AddAll([FromRoute] Guid buildId, [FromBody] ContentAll request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
Option<FillerKind> maybeFillerKind = Option<FillerKind>.None;
if (Enum.TryParse(request.FillerKind, ignoreCase: true, out FillerKind fk))
{
maybeFillerKind = fk;
}
engine.AddAll(request.Content, maybeFillerKind, request.CustomTitle, request.DisableWatermarks);
return GetContextInternal(engine);
}
[HttpPost("add_count", Name = "AddCount")]
[Tags("Scripted Scheduling")]
[EndpointSummary("Add a specific number of content items")]
public ActionResult<PlayoutContext> AddCount(
[FromRoute]
Guid buildId,
[FromBody]
PlayoutCount request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
Option<FillerKind> maybeFillerKind = Option<FillerKind>.None;
if (Enum.TryParse(request.FillerKind, ignoreCase: true, out FillerKind fk))
{
maybeFillerKind = fk;
}
engine.AddCount(
request.Content,
request.Count,
maybeFillerKind,
request.CustomTitle,
request.DisableWatermarks);
return GetContextInternal(engine);
}
[HttpPost("add_duration", Name = "AddDuration")]
[Tags("Scripted Scheduling")]
[EndpointSummary("Add content for a specific duration")]
public ActionResult<PlayoutContext> AddDuration(
[FromRoute]
Guid buildId,
[FromBody]
PlayoutDuration request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
Option<FillerKind> maybeFillerKind = Option<FillerKind>.None;
if (Enum.TryParse(request.FillerKind, ignoreCase: true, out FillerKind fk))
{
maybeFillerKind = fk;
}
engine.AddDuration(
request.Content,
request.Duration,
request.Fallback,
request.Trim,
request.DiscardAttempts,
request.StopBeforeEnd,
request.OfflineTail,
maybeFillerKind,
request.CustomTitle,
request.DisableWatermarks);
return GetContextInternal(engine);
}
[HttpPost("pad_to_next", Name = "PadToNext")]
[Tags("Scripted Scheduling")]
[EndpointSummary("Add content until a specific minutes interval")]
public ActionResult<PlayoutContext> PadToNext(
[FromRoute] Guid buildId,
[FromBody] PlayoutPadToNext request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
Option<FillerKind> maybeFillerKind = Option<FillerKind>.None;
if (Enum.TryParse(request.FillerKind, ignoreCase: true, out FillerKind fk))
{
maybeFillerKind = fk;
}
engine.PadToNext(
request.Content,
request.Minutes,
request.Fallback,
request.Trim,
request.DiscardAttempts,
request.StopBeforeEnd,
request.OfflineTail,
maybeFillerKind,
request.CustomTitle,
request.DisableWatermarks);
return GetContextInternal(engine);
}
[HttpPost("pad_until", Name = "PadUntil")]
[Tags("Scripted Scheduling")]
[EndpointSummary("Add content until a specified time of day")]
public ActionResult<PlayoutContext> PadUntil(
[FromRoute] Guid buildId,
[FromBody] PlayoutPadUntil request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
Option<FillerKind> maybeFillerKind = Option<FillerKind>.None;
if (Enum.TryParse(request.FillerKind, ignoreCase: true, out FillerKind fk))
{
maybeFillerKind = fk;
}
engine.PadUntil(
request.Content,
request.When,
request.Tomorrow,
request.Fallback,
request.Trim,
request.DiscardAttempts,
request.StopBeforeEnd,
request.OfflineTail,
maybeFillerKind,
request.CustomTitle,
request.DisableWatermarks);
return GetContextInternal(engine);
}
[HttpPost("pad_until_exact", Name = "PadUntilExact")]
[Tags("Scripted Scheduling")]
[EndpointSummary("Add content until an exact time")]
public ActionResult<PlayoutContext> PadUntilExact(
[FromRoute]
Guid buildId,
[FromBody]
PlayoutPadUntilExact request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
Option<FillerKind> maybeFillerKind = Option<FillerKind>.None;
if (Enum.TryParse(request.FillerKind, ignoreCase: true, out FillerKind fk))
{
maybeFillerKind = fk;
}
engine.PadUntilExact(
request.Content,
request.When,
request.Fallback,
request.Trim,
request.DiscardAttempts,
request.StopBeforeEnd,
request.OfflineTail,
maybeFillerKind,
request.CustomTitle,
request.DisableWatermarks);
return GetContextInternal(engine);
}
[HttpGet("peek_next/{content}", Name = "PeekNext")]
[Tags("Scripted Scheduling")]
[EndpointSummary("Peek the next content item")]
public ActionResult<PeekItemDuration> PeekNext(Guid buildId, string content)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
Option<MediaItem> maybeMediaItem = engine.PeekNext(content);
foreach (var mediaItem in maybeMediaItem)
{
return new PeekItemDuration
{
Content = content,
Milliseconds = (long)mediaItem.GetDurationForPlayout().TotalMilliseconds
};
}
return NotFound("Content key does not exist, or collection is empty");
}
[HttpPost("start_epg_group", Name = "StartEpgGroup")]
[Tags("Scripted Control")]
[EndpointSummary("Start an EPG group")]
public IActionResult StartEpgGroup([FromRoute] Guid buildId, [FromBody] ControlStartEpgGroup request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
engine.LockGuideGroup(request.Advance, request.CustomTitle);
return Ok();
}
[HttpPost("stop_epg_group", Name = "StopEpgGroup")]
[Tags("Scripted Control")]
[EndpointSummary("Finish an EPG group")]
public IActionResult StopEpgGroup([FromRoute] Guid buildId)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
engine.UnlockGuideGroup();
return Ok();
}
[HttpPost("graphics_on", Name = "GraphicsOn")]
[Tags("Scripted Control")]
[EndpointSummary("Turn on graphics elements")]
public async Task<IActionResult> GraphicsOn(
[FromRoute]
Guid buildId,
[FromBody]
ControlGraphicsOn request,
CancellationToken cancellationToken = default)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
await engine.GraphicsOn(request.Graphics, request.Variables, cancellationToken);
return Ok();
}
[HttpPost("graphics_off", Name = "GraphicsOff")]
[Tags("Scripted Control")]
[EndpointSummary("Turn off graphics elements")]
public async Task<IActionResult> GraphicsOff(
[FromRoute]
Guid buildId,
[FromBody]
ControlGraphicsOff request,
CancellationToken cancellationToken = default)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
await engine.GraphicsOff(request.Graphics, cancellationToken);
return Ok();
}
[HttpPost("watermark_on", Name = "WatermarkOn")]
[Tags("Scripted Control")]
[EndpointSummary("Turn on watermarks")]
public async Task<IActionResult> WatermarkOn([FromRoute] Guid buildId, [FromBody] ControlWatermarkOn request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
await engine.WatermarkOn(request.Watermark);
return Ok();
}
[HttpPost("watermark_off", Name = "WatermarkOff")]
[Tags("Scripted Control")]
[EndpointSummary("Turn off watermarks")]
public async Task<IActionResult> WatermarkOff([FromRoute] Guid buildId, [FromBody] ControlWatermarkOff request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
await engine.WatermarkOff(request.Watermark);
return Ok();
}
[HttpPost("pre_roll_on", Name = "PreRollOn")]
[Tags("Scripted Control")]
[EndpointSummary("Turn on pre-roll playlist")]
public IActionResult PreRollOn([FromRoute] Guid buildId, [FromBody] ControlPreRollOn request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
engine.PreRollOn(request.Playlist);
return Ok();
}
[HttpPost("pre_roll_off", Name = "PreRollOff")]
[Tags("Scripted Control")]
[EndpointSummary("Turn off pre-roll playlist")]
public IActionResult PreRollOff([FromRoute] Guid buildId)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
engine.PreRollOff();
return Ok();
}
[HttpPost("skip_items", Name = "SkipItems")]
[Tags("Scripted Control")]
[EndpointSummary("Skip a specific number of items")]
public IActionResult SkipItems([FromRoute] Guid buildId, [FromBody] ControlSkipItems request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
engine.SkipItems(request.Content, request.Count);
return Ok();
}
[HttpPost("skip_to_item", Name = "SkipToItem")]
[Tags("Scripted Control")]
[EndpointSummary("Skip to a specific episode")]
public IActionResult SkipToItem([FromRoute] Guid buildId, [FromBody] ControlSkipToItem request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
engine.SkipToItem(request.Content, request.Season, request.Episode);
return Ok();
}
[HttpPost("wait_until_exact", Name = "WaitUntilExact")]
[Tags("Scripted Control")]
[EndpointSummary("Wait until an exact time")]
public ActionResult<PlayoutContext> WaitUntilExact(
[FromRoute]
Guid buildId,
[FromBody]
ControlWaitUntilExact request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
engine.WaitUntilExact(request.When, request.RewindOnReset);
return GetContextInternal(engine);
}
[HttpPost("wait_until", Name = "WaitUntil")]
[Tags("Scripted Control")]
[EndpointSummary("Wait until the specified time of day")]
public ActionResult<PlayoutContext> WaitUntilTime(
[FromRoute]
Guid buildId,
[FromBody]
ControlWaitUntil request)
{
ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId);
if (engine == null)
{
return NotFound($"Active build engine not found for build {buildId}.");
}
if (TimeOnly.TryParse(request.When, out TimeOnly waitUntil))
{
engine.WaitUntil(waitUntil, request.Tomorrow, request.RewindOnReset);
}
return GetContextInternal(engine);
}
private ActionResult<PlayoutContext> GetContextInternal(ISchedulingEngine engine)
{
try
{
var state = engine.GetState();
var responseModel = new PlayoutContext
{
CurrentTime = state.CurrentTime,
StartTime = state.Start,
FinishTime = state.Finish,
IsDone = state.IsDone
};
return Ok(responseModel);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
}