diff --git a/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayout.cs b/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayout.cs new file mode 100644 index 000000000..8ff958388 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayout.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Playouts; + +public record UpdateScriptedPlayout(int PlayoutId, string ScheduleFile) + : IRequest>; diff --git a/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs new file mode 100644 index 000000000..d89ab8e7e --- /dev/null +++ b/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs @@ -0,0 +1,62 @@ +using System.Threading.Channels; +using ErsatzTV.Application.Channels; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Playouts; + +public class + UpdateScriptedPlayoutHandler( + IDbContextFactory dbContextFactory, + ChannelWriter workerChannel) + : IRequestHandler> +{ + public async Task> Handle( + UpdateScriptedPlayout request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + Validation validation = await Validate(dbContext, request); + return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout)); + } + + private async Task ApplyUpdateRequest( + TvContext dbContext, + UpdateScriptedPlayout request, + Playout playout) + { + playout.ScheduleFile = request.ScheduleFile; + + if (await dbContext.SaveChangesAsync() > 0) + { + await workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number)); + } + + return new PlayoutNameViewModel( + playout.Id, + playout.ScheduleKind, + playout.Channel.Name, + playout.Channel.Number, + playout.Channel.PlayoutMode, + playout.ProgramSchedule?.Name ?? string.Empty, + playout.ScheduleFile, + playout.DailyRebuildTime); + } + + private static Task> Validate( + TvContext dbContext, + UpdateScriptedPlayout request) => + PlayoutMustExist(dbContext, request); + + private static Task> PlayoutMustExist( + TvContext dbContext, + UpdateScriptedPlayout updatePlayout) => + dbContext.Playouts + .Include(p => p.Channel) + .SelectOneAsync(p => p.Id, p => p.Id == updatePlayout.PlayoutId) + .Map(o => o.ToValidation("Playout does not exist.")); +} diff --git a/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs b/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs index e2b9e27b4..a954e28dc 100644 --- a/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs +++ b/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs @@ -38,6 +38,12 @@ public interface ISchedulingEngine Task AddShow(string key, Dictionary guids, PlaybackOrder playbackOrder); // content instructions + bool AddAll( + string content, + Option fillerKind, + string customTitle, + bool disableWatermarks); + bool AddCount( string content, int count, @@ -45,6 +51,41 @@ public interface ISchedulingEngine string customTitle, bool disableWatermarks); + bool AddDuration( + string content, + string duration, + string fallback, + bool trim, + int discardAttempts, + bool stopBeforeEnd, + bool offlineTail, + Option maybeFillerKind, + string customTitle, + bool disableWatermarks); + + bool PadToNext( + string content, + int minutes, + string fallback, + bool trim, + int discardAttempts, + Option maybeFillerKind, + string customTitle, + bool disableWatermarks); + + bool PadUntil( + string content, + string padUntil, + bool tomorrow, + string fallback, + bool trim, + int discardAttempts, + bool stopBeforeEnd, + bool offlineTail, + Option maybeFillerKind, + string customTitle, + bool disableWatermarks); + // control instructions void LockGuideGroup(bool advance); void UnlockGuideGroup(); diff --git a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs index 77d5f892c..34b3761f6 100644 --- a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs +++ b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs @@ -8,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Scheduling.BlockScheduling; using Microsoft.Extensions.Logging; using Newtonsoft.Json; +using TimeSpanParserUtil; namespace ErsatzTV.Core.Scheduling.Engine; @@ -355,6 +356,22 @@ public class SchedulingEngine( } } + public bool AddAll(string content, Option fillerKind, string customTitle, bool disableWatermarks) + { + if (!_enumerators.TryGetValue(content, out EnumeratorDetails enumeratorDetails)) + { + logger.LogWarning("Skipping invalid content {Key}", content); + return false; + } + + return AddCountInternal( + enumeratorDetails, + enumeratorDetails.Enumerator.Count, + fillerKind, + customTitle, + disableWatermarks); + } + public bool AddCount( string content, int count, @@ -368,6 +385,363 @@ public class SchedulingEngine( return false; } + return AddCountInternal(enumeratorDetails, count, fillerKind, customTitle, disableWatermarks); + } + + public bool AddDuration( + string content, + string duration, + string fallback, + bool trim, + int discardAttempts, + bool stopBeforeEnd, + bool offlineTail, + Option maybeFillerKind, + string customTitle, + bool disableWatermarks) + { + if (!TimeSpanParser.TryParse(duration, out TimeSpan timeSpan)) + { + logger.LogWarning("Skipping invalid duration {Duration} for content {Key}", duration, content); + return false; + } + + if (!stopBeforeEnd && offlineTail) + { + logger.LogError("offline_tail must be false when stop_before_end is false"); + return false; + } + + if (!_enumerators.TryGetValue(content, out EnumeratorDetails enumeratorDetails)) + { + logger.LogWarning("Skipping invalid content {Key}", content); + return false; + } + + EnumeratorDetails fallbackEnumeratorDetails = null; + if (!string.IsNullOrEmpty(fallback)) + { + _enumerators.TryGetValue(fallback, out fallbackEnumeratorDetails); + } + + DateTimeOffset targetTime = _state.CurrentTime.Add(timeSpan); + + _state.CurrentTime = AddDurationInternal( + targetTime, + stopBeforeEnd, + discardAttempts, + trim, + offlineTail, + GetFillerKind(maybeFillerKind), + customTitle, + disableWatermarks, + enumeratorDetails, + Optional(fallbackEnumeratorDetails)); + + return true; + } + + public bool PadToNext( + string content, + int minutes, + string fallback, + bool trim, + int discardAttempts, + Option maybeFillerKind, + string customTitle, + bool disableWatermarks) + { + if (!_enumerators.TryGetValue(content, out EnumeratorDetails enumeratorDetails)) + { + logger.LogWarning("Skipping invalid content {Key}", content); + return false; + } + + EnumeratorDetails fallbackEnumeratorDetails = null; + if (!string.IsNullOrEmpty(fallback)) + { + _enumerators.TryGetValue(fallback, out fallbackEnumeratorDetails); + } + + int currentMinute = _state.CurrentTime.Minute; + + int targetMinute = (currentMinute + minutes - 1) / minutes * minutes; + + DateTimeOffset almostTargetTime = + _state.CurrentTime - TimeSpan.FromMinutes(currentMinute) + TimeSpan.FromMinutes(targetMinute); + + var targetTime = new DateTimeOffset( + almostTargetTime.Year, + almostTargetTime.Month, + almostTargetTime.Day, + almostTargetTime.Hour, + almostTargetTime.Minute, + 0, + almostTargetTime.Offset); + + // ensure filler works for content less than one minute + if (targetTime <= _state.CurrentTime) + { + targetTime = targetTime.AddMinutes(minutes); + } + + _state.CurrentTime = AddDurationInternal( + targetTime, + stopBeforeEnd: true, + discardAttempts, + trim, + offlineTail: true, + GetFillerKind(maybeFillerKind), + customTitle, + disableWatermarks, + enumeratorDetails, + Optional(fallbackEnumeratorDetails)); + + return true; + } + + public bool PadUntil( + string content, + string padUntil, + bool tomorrow, + string fallback, + bool trim, + int discardAttempts, + bool stopBeforeEnd, + bool offlineTail, + Option maybeFillerKind, + string customTitle, + bool disableWatermarks) + { + if (!_enumerators.TryGetValue(content, out EnumeratorDetails enumeratorDetails)) + { + logger.LogWarning("Skipping invalid content {Key}", content); + return false; + } + + EnumeratorDetails fallbackEnumeratorDetails = null; + if (!string.IsNullOrEmpty(fallback)) + { + _enumerators.TryGetValue(fallback, out fallbackEnumeratorDetails); + } + + if (!TimeOnly.TryParse(padUntil, out TimeOnly padUntilTime)) + { + logger.LogWarning("Skipping pad_until with invalid 'when' {When}", padUntil); + return false; + } + + DateTimeOffset targetTime = _state.CurrentTime; + + var dayOnly = DateOnly.FromDateTime(targetTime.LocalDateTime); + var timeOnly = TimeOnly.FromDateTime(targetTime.LocalDateTime); + + if (timeOnly > padUntilTime) + { + if (tomorrow) + { + // this is wrong when offset changes + dayOnly = dayOnly.AddDays(1); + targetTime = new DateTimeOffset(dayOnly, padUntilTime, targetTime.Offset); + } + } + else + { + // this is wrong when offset changes + targetTime = new DateTimeOffset(dayOnly, padUntilTime, targetTime.Offset); + } + + _state.CurrentTime = AddDurationInternal( + targetTime, + stopBeforeEnd, + discardAttempts, + trim, + offlineTail, + GetFillerKind(maybeFillerKind), + customTitle, + disableWatermarks, + enumeratorDetails, + Optional(fallbackEnumeratorDetails)); + + return true; + } + + private DateTimeOffset AddDurationInternal( + DateTimeOffset targetTime, + bool stopBeforeEnd, + int discardAttempts, + bool trim, + bool offlineTail, + FillerKind fillerKind, + string customTitle, + bool disableWatermarks, + EnumeratorDetails enumeratorDetails, + Option maybeFallbackEnumeratorDetails) + { + var done = false; + TimeSpan remainingToFill = targetTime - _state.CurrentTime; + while (!done && enumeratorDetails.Enumerator.Current.IsSome && remainingToFill > TimeSpan.Zero) + { + // foreach (string preRollSequence in context.GetPreRollSequence()) + // { + // context.PushFillerKind(FillerKind.PreRoll); + // await executeSequence(preRollSequence); + // context.PopFillerKind(); + // + // remainingToFill = targetTime - context.CurrentTime; + // if (remainingToFill <= TimeSpan.Zero) + // { + // break; + // } + // } + + foreach (MediaItem mediaItem in enumeratorDetails.Enumerator.Current) + { + TimeSpan itemDuration = DurationForMediaItem(mediaItem); + + var playoutItem = new PlayoutItem + { + PlayoutId = _state.PlayoutId, + MediaItemId = mediaItem.Id, + Start = _state.CurrentTime.UtcDateTime, + Finish = _state.CurrentTime.UtcDateTime + itemDuration, + InPoint = TimeSpan.Zero, + OutPoint = itemDuration, + GuideGroup = _state.PeekNextGuideGroup(), + FillerKind = fillerKind, + CustomTitle = string.IsNullOrWhiteSpace(customTitle) ? null : customTitle, + DisableWatermarks = disableWatermarks, + PlayoutItemWatermarks = [], + PlayoutItemGraphicsElements = [] + }; + + foreach (int watermarkId in _state.GetChannelWatermarkIds()) + { + playoutItem.PlayoutItemWatermarks.Add( + new PlayoutItemWatermark + { + PlayoutItem = playoutItem, + WatermarkId = watermarkId + }); + } + + foreach ((int graphicsElementId, string variablesJson) in _state.GetGraphicsElements()) + { + playoutItem.PlayoutItemGraphicsElements.Add( + new PlayoutItemGraphicsElement + { + PlayoutItem = playoutItem, + GraphicsElementId = graphicsElementId, + Variables = variablesJson + }); + } + + if (remainingToFill - itemDuration >= TimeSpan.Zero || !stopBeforeEnd) + { + _state.AddedItems.Add(playoutItem); + _state.AdvanceGuideGroup(); + + // create history record + List maybeHistory = GetHistoryForItem(enumeratorDetails, playoutItem, mediaItem); + foreach (PlayoutHistory history in maybeHistory) + { + _state.AddedHistory.Add(history); + } + + remainingToFill -= itemDuration; + _state.CurrentTime += itemDuration; + + enumeratorDetails.Enumerator.MoveNext(); + } + else if (discardAttempts > 0) + { + // item won't fit; try the next one + discardAttempts--; + enumeratorDetails.Enumerator.MoveNext(); + } + else if (trim) + { + // trim item to exactly fit + playoutItem.Finish = targetTime.UtcDateTime; + playoutItem.OutPoint = playoutItem.Finish - playoutItem.Start; + + _state.AddedItems.Add(playoutItem); + _state.AdvanceGuideGroup(); + + // create history record + List maybeHistory = GetHistoryForItem(enumeratorDetails, playoutItem, mediaItem); + foreach (PlayoutHistory history in maybeHistory) + { + _state.AddedHistory.Add(history); + } + + remainingToFill = TimeSpan.Zero; + _state.CurrentTime = targetTime; + + enumeratorDetails.Enumerator.MoveNext(); + } + else if (maybeFallbackEnumeratorDetails.IsSome) + { + foreach (EnumeratorDetails fallbackEnumeratorDetails in maybeFallbackEnumeratorDetails) + { + remainingToFill = TimeSpan.Zero; + _state.CurrentTime = targetTime; + done = true; + + // replace with fallback content + foreach (MediaItem fallbackItem in fallbackEnumeratorDetails.Enumerator.Current) + { + playoutItem.MediaItemId = fallbackItem.Id; + playoutItem.Finish = targetTime.UtcDateTime; + playoutItem.FillerKind = FillerKind.Fallback; + + _state.AddedItems.Add(playoutItem); + + // create history record + List maybeHistory = GetHistoryForItem( + fallbackEnumeratorDetails, + playoutItem, + mediaItem); + + foreach (PlayoutHistory history in maybeHistory) + { + _state.AddedHistory.Add(history); + } + + fallbackEnumeratorDetails.Enumerator.MoveNext(); + } + } + } + else + { + // item won't fit; we're done + done = true; + } + } + + // foreach (string postRollSequence in context.GetPostRollSequence()) + // { + // context.PushFillerKind(FillerKind.PostRoll); + // await executeSequence(postRollSequence); + // context.PopFillerKind(); + // } + } + + if (!stopBeforeEnd) + { + return _state.CurrentTime; + } + + return offlineTail ? targetTime : _state.CurrentTime; + } + + private bool AddCountInternal( + EnumeratorDetails enumeratorDetails, + int count, + Option fillerKind, + string customTitle, + bool disableWatermarks) + { var result = false; for (var i = 0; i < count; i++) diff --git a/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs b/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs index 31aa46cc7..ec9747bb8 100644 --- a/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs +++ b/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs @@ -15,6 +15,29 @@ public class PlayoutModule(ISchedulingEngine schedulingEngine) // content instructions + public void add_all( + string content, + string filler_kind = null, + string custom_title = null, + bool disable_watermarks = false) + { + Option maybeFillerKind = Option.None; + if (Enum.TryParse(filler_kind, ignoreCase: true, out FillerKind fillerKind)) + { + maybeFillerKind = fillerKind; + } + + bool success = schedulingEngine.AddAll(content, maybeFillerKind, custom_title, disable_watermarks); + if (success) + { + FailureCount = 0; + } + else + { + FailureCount++; + } + } + public void add_count( string content, int count, @@ -39,6 +62,124 @@ public class PlayoutModule(ISchedulingEngine schedulingEngine) } } + public void add_duration( + string content, + string duration, + string fallback = null, + bool trim = false, + int discard_attempts = 0, + bool stop_before_end = true, + bool offline_tail = false, + string filler_kind = null, + string custom_title = null, + bool disable_watermarks = false) + { + Option maybeFillerKind = Option.None; + if (Enum.TryParse(filler_kind, ignoreCase: true, out FillerKind fillerKind)) + { + maybeFillerKind = fillerKind; + } + + bool success = schedulingEngine.AddDuration( + content, + duration, + fallback, + trim, + discard_attempts, + stop_before_end, + offline_tail, + maybeFillerKind, + custom_title, + disable_watermarks); + + if (success) + { + FailureCount = 0; + } + else + { + FailureCount++; + } + } + + public void pad_to_next( + string content, + int minutes, + string fallback = null, + bool trim = false, + int discard_attempts = 0, + string filler_kind = null, + string custom_title = null, + bool disable_watermarks = false) + { + Option maybeFillerKind = Option.None; + if (Enum.TryParse(filler_kind, ignoreCase: true, out FillerKind fillerKind)) + { + maybeFillerKind = fillerKind; + } + + bool success = schedulingEngine.PadToNext( + content, + minutes, + fallback, + trim, + discard_attempts, + maybeFillerKind, + custom_title, + disable_watermarks); + + if (success) + { + FailureCount = 0; + } + else + { + FailureCount++; + } + } + + public void pad_until( + string content, + string when, + bool tomorrow = false, + string fallback = null, + bool trim = false, + int discard_attempts = 0, + bool stop_before_end = true, + bool offline_tail = false, + string filler_kind = null, + string custom_title = null, + bool disable_watermarks = false) + { + Option maybeFillerKind = Option.None; + if (Enum.TryParse(filler_kind, ignoreCase: true, out FillerKind fillerKind)) + { + maybeFillerKind = fillerKind; + } + + bool success = schedulingEngine.PadUntil( + content, + when, + tomorrow, + fallback, + trim, + discard_attempts, + stop_before_end, + offline_tail, + maybeFillerKind, + custom_title, + disable_watermarks); + + if (success) + { + FailureCount = 0; + } + else + { + FailureCount++; + } + } + // control instructions diff --git a/ErsatzTV/Pages/ScriptedPlayoutEditor.razor b/ErsatzTV/Pages/ScriptedPlayoutEditor.razor new file mode 100644 index 000000000..19ce34692 --- /dev/null +++ b/ErsatzTV/Pages/ScriptedPlayoutEditor.razor @@ -0,0 +1,106 @@ +@page "/playouts/scripted/{Id:int}" +@using ErsatzTV.Application.Channels +@using ErsatzTV.Application.Playouts +@using ErsatzTV.Application.Scheduling +@implements IDisposable +@inject NavigationManager NavigationManager +@inject ISnackbar Snackbar +@inject IMediator Mediator +@inject IEntityLocker EntityLocker; +@inject ILogger Logger + + + + + Save Scripted Schedule + + +
+ + @_channelName - Scripted Schedule + + +
+ Scripted Schedule +
+ +
+ Maintenance + + +
+ Playout Items and History +
+ + Erase Items and History + +
+
+
+
+ +@code { + private readonly CancellationTokenSource _cts = new(); + private PlayoutNameViewModel _playout; + + [Parameter] + public int Id { get; set; } + + private string _channelName; + + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } + + protected override async Task OnParametersSetAsync() + { + Option maybeName = await Mediator.Send(new GetChannelNameByPlayoutId(Id), _cts.Token); + if (maybeName.IsNone) + { + NavigationManager.NavigateTo("playouts"); + return; + } + + foreach (string name in maybeName) + { + _channelName = name; + } + + Option maybePlayout = await Mediator.Send(new GetPlayoutById(Id), _cts.Token); + foreach (PlayoutNameViewModel playout in maybePlayout) + { + _playout = playout; + } + } + + private async Task EraseItems(bool eraseHistory) + { + IRequest request = eraseHistory ? new ErasePlayoutHistory(Id) : new ErasePlayoutItems(Id); + await Mediator.Send(request, _cts.Token); + + string message = eraseHistory ? "Erased playout items and history" : "Erased playout items"; + Snackbar.Add(message, Severity.Info); + } + + private async Task SaveChanges() + { + if (_playout is null) + { + return; + } + + Either result = + await Mediator.Send(new UpdateScriptedPlayout(_playout.PlayoutId, _playout.ScheduleFile), _cts.Token); + + result.Match( + _ => { Snackbar.Add($"Saved scripted schedule for playout {_channelName}", Severity.Success); }, + error => + { + Snackbar.Add($"Unexpected error saving scripted schedule: {error.Value}", Severity.Error); + Logger.LogError("Unexpected error saving scripted schedule: {Error}", error.Value); + }); + } + +}