From e96ac0202b31271a511ba03b2841a9b403a5a008 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sun, 7 Sep 2025 11:45:41 -0500 Subject: [PATCH] add mirror playout offset (#2391) --- CHANGELOG.md | 2 + .../Channels/ChannelViewModel.cs | 1 + .../Channels/Commands/CreateChannel.cs | 1 + .../Channels/Commands/CreateChannelHandler.cs | 2 + .../Commands/RefreshChannelDataHandler.cs | 39 +++++-- .../Commands/RefreshChannelListHandler.cs | 2 +- .../Channels/Commands/UpdateChannel.cs | 1 + .../Channels/Commands/UpdateChannelHandler.cs | 3 + ErsatzTV.Application/Channels/Mapper.cs | 1 + .../Commands/PreviewPlaylistPlayoutHandler.cs | 3 +- .../Playouts/Commands/BuildPlayoutHandler.cs | 30 ++++- .../Commands/PreviewBlockPlayoutHandler.cs | 3 +- .../Streaming/Queries/FFmpegProcessHandler.cs | 8 +- .../ClassicScheduling/ContinuePlayoutTests.cs | 16 ++- .../ClassicScheduling/NewPlayoutTests.cs | 110 ++++++++++++++++-- .../PlayoutBuilderTestBase.cs | 6 +- .../ClassicScheduling/RefreshPlayoutTests.cs | 3 +- .../Scheduling/ScheduleIntegrationTests.cs | 3 +- .../Scheduling/PlayoutReferenceData.cs | 3 +- ErsatzTV/Pages/ChannelEditor.razor | 10 ++ ErsatzTV/ViewModels/ChannelEditViewModel.cs | 10 ++ 21 files changed, 220 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 038fc3fab..e031aa8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `Generated`: default/existing behavior where channel must have its own playout - `Mirror`: channel will play content from the specified `Mirror Source Channel`'s playout - This allows the exact same content on different channels with different channel settings + - `Playout Offset` can be used to offset the times of scheduled playout items from the mirror source channel + - e.g. -2 hours will cause the mirror channel to play content 2 hours before the mirror source channel ### Fixed - Fix transcoding content with bt709/pc color metadata diff --git a/ErsatzTV.Application/Channels/ChannelViewModel.cs b/ErsatzTV.Application/Channels/ChannelViewModel.cs index 7bf5acd6a..3a05b214b 100644 --- a/ErsatzTV.Application/Channels/ChannelViewModel.cs +++ b/ErsatzTV.Application/Channels/ChannelViewModel.cs @@ -19,6 +19,7 @@ public record ChannelViewModel( ChannelPlayoutSource PlayoutSource, ChannelPlayoutMode PlayoutMode, int? MirrorSourceChannelId, + TimeSpan? PlayoutOffset, StreamingMode StreamingMode, int? WatermarkId, int? FallbackFillerId, diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannel.cs b/ErsatzTV.Application/Channels/Commands/CreateChannel.cs index 0a29143c0..7050c9d7d 100644 --- a/ErsatzTV.Application/Channels/Commands/CreateChannel.cs +++ b/ErsatzTV.Application/Channels/Commands/CreateChannel.cs @@ -18,6 +18,7 @@ public record CreateChannel( ChannelPlayoutSource PlayoutSource, ChannelPlayoutMode PlayoutMode, int? MirrorSourceChannelId, + TimeSpan? PlayoutOffset, StreamingMode StreamingMode, int? WatermarkId, int? FallbackFillerId, diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs index 181faa9af..104c243c6 100644 --- a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs @@ -79,6 +79,7 @@ public class CreateChannelHandler( PlayoutSource = request.PlayoutSource, PlayoutMode = request.PlayoutMode, MirrorSourceChannelId = request.MirrorSourceChannelId, + PlayoutOffset = request.PlayoutOffset, StreamingMode = request.StreamingMode, Artwork = artwork, StreamSelectorMode = request.StreamSelectorMode, @@ -103,6 +104,7 @@ public class CreateChannelHandler( else { channel.MirrorSourceChannelId = null; + channel.PlayoutOffset = null; } foreach (int id in watermarkId) diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs index a324906db..d49612039 100644 --- a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs @@ -11,6 +11,7 @@ using ErsatzTV.Core.Iptv; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Streaming; using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.IO; @@ -52,10 +53,8 @@ public class RefreshChannelDataHandler : IRequestHandler string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml"); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - string channelNumber = request.ChannelNumber; - int hiddenCount = await dbContext.Channels - .Where(c => c.Number == channelNumber && c.ShowInEpg == false) + .Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false) .CountAsync(cancellationToken); if (hiddenCount > 0) { @@ -99,20 +98,22 @@ public class RefreshChannelDataHandler : IRequestHandler string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken); var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName); - Option maybeMirrorNumber = await dbContext.Channels - .Filter(c => c.Number == channelNumber) + TimeSpan playoutOffset = TimeSpan.Zero; + string mirrorChannelNumber = null; + Option maybeChannel = await dbContext.Channels + .AsNoTracking() + .Include(c => c.MirrorSourceChannel) .Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null) - .Map(c => c.MirrorSourceChannel.Number) - .ToListAsync(cancellationToken) - .Map(list => list.HeadOrNone()); - foreach (string mirrorNumber in maybeMirrorNumber) + .SelectOneAsync(c => c.Number == request.ChannelNumber, c => c.Number == request.ChannelNumber, cancellationToken); + foreach (Channel channel in maybeChannel) { - request = new RefreshChannelData(ChannelNumber: mirrorNumber); + mirrorChannelNumber = channel.MirrorSourceChannel.Number; + playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero; } List playouts = await dbContext.Playouts .AsNoTracking() - .Filter(pi => pi.Channel.Number == request.ChannelNumber) + .Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber)) .Include(p => p.Items) .ThenInclude(i => i.MediaItem) .ThenInclude(i => (i as Episode).EpisodeMetadata) @@ -213,6 +214,11 @@ public class RefreshChannelDataHandler : IRequestHandler .OrderBy(pi => pi.Start) .Filter(pi => pi.StartOffset <= finish) .ToList(); + foreach (var item in floodSorted) + { + item.Start += playoutOffset; + item.Finish += playoutOffset; + } await WritePlayoutXml( request, floodSorted, @@ -232,6 +238,11 @@ public class RefreshChannelDataHandler : IRequestHandler .OrderBy(pi => pi.Start) .Filter(pi => pi.StartOffset <= finish) .ToList(); + foreach (var item in blockSorted) + { + item.Start += playoutOffset; + item.Finish += playoutOffset; + } await WriteBlockPlayoutXml( request, blockSorted, @@ -249,7 +260,11 @@ public class RefreshChannelDataHandler : IRequestHandler var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile)) .Filter(pi => pi.StartOffset <= finish) .ToList(); - + foreach (var item in externalJsonSorted) + { + item.Start += playoutOffset; + item.Finish += playoutOffset; + } await WritePlayoutXml( request, externalJsonSorted, diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs index c4d4b9377..92c540eca 100644 --- a/ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs @@ -118,7 +118,7 @@ public class RefreshChannelListHandler : IRequestHandler const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath from Channel C left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2 - where C.Id in (select ChannelId from Playout) and C.IsEnabled = 1 and C.ShowInEPG = 1 + where (C.Id in (select ChannelId from Playout) or C.MirrorSourceChannelId in (select ChannelId from Playout)) and C.IsEnabled = 1 and C.ShowInEPG = 1 order by CAST(C.Number as double)"; // TODO: this needs to be fixed for sqlite/mariadb diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs index e96f8ebe8..e1f18a542 100644 --- a/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs @@ -19,6 +19,7 @@ public record UpdateChannel( ChannelPlayoutSource PlayoutSource, ChannelPlayoutMode PlayoutMode, int? MirrorSourceChannelId, + TimeSpan? PlayoutOffset, StreamingMode StreamingMode, int? WatermarkId, int? FallbackFillerId, diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs index ae49a86cd..1246508b0 100644 --- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs @@ -108,13 +108,16 @@ public class UpdateChannelHandler( { c.PlayoutMode = ChannelPlayoutMode.Continuous; hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId; + hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset; } else { c.MirrorSourceChannelId = null; + c.PlayoutOffset = null; } c.MirrorSourceChannelId = update.MirrorSourceChannelId; + c.PlayoutOffset = update.PlayoutOffset; c.StreamingMode = update.StreamingMode; c.WatermarkId = update.WatermarkId; c.FallbackFillerId = update.FallbackFillerId; diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index 2afd5011a..d585084ed 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -22,6 +22,7 @@ internal static class Mapper channel.PlayoutSource, channel.PlayoutMode, channel.MirrorSourceChannelId, + channel.PlayoutOffset, channel.StreamingMode, channel.WatermarkId, channel.FallbackFillerId, diff --git a/ErsatzTV.Application/MediaCollections/Commands/PreviewPlaylistPlayoutHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/PreviewPlaylistPlayoutHandler.cs index 375e06add..d9763eaed 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/PreviewPlaylistPlayoutHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/PreviewPlaylistPlayoutHandler.cs @@ -50,7 +50,8 @@ public class PreviewPlaylistPlayoutHandler( playout.Templates.ToList(), new ProgramSchedule(), playout.ProgramScheduleAlternates.ToList(), - playout.PlayoutHistory.ToList()); + playout.PlayoutHistory.ToList(), + TimeSpan.Zero); // TODO: make an explicit method to preview, this is ugly playoutBuilder.TrimStart = false; diff --git a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs index 8a9c439e6..106221c74 100644 --- a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs @@ -171,7 +171,7 @@ public class BuildPlayoutHandler : IRequestHandler pi.PlayoutId == playout.Id) - .Where(pi => pi.Finish < removeBefore.UtcDateTime) + .Where(pi => pi.Finish < removeBefore.UtcDateTime + referenceData.MinPlayoutOffset) .ExecuteDeleteAsync(cancellationToken); } @@ -242,6 +242,17 @@ public class BuildPlayoutHandler : IRequestHandler maybeMirrors = await dbContext.Channels + .AsNoTracking() + .Filter(c => c.MirrorSourceChannelId == referenceData.Channel.Id) + .Map(c => c.Number) + .ToListAsync(cancellationToken); + foreach (string mirror in maybeMirrors) + { + await _workerChannel.WriteAsync(new RefreshChannelData(mirror), cancellationToken); + } } await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken); @@ -338,6 +349,20 @@ public class BuildPlayoutHandler : IRequestHandler c.Playouts.Any(p => p.Id == playoutId)) .FirstOrDefaultAsync(); + TimeSpan minPlayoutOffset = TimeSpan.Zero; + List mirrorChannels = await dbContext.Channels + .AsNoTracking() + .Where(c => c.MirrorSourceChannelId == channel.Id) + .ToListAsync(); + foreach (var mirrorChannel in mirrorChannels) + { + var offset = mirrorChannel.PlayoutOffset ?? TimeSpan.Zero; + if (offset < minPlayoutOffset) + { + minPlayoutOffset = offset; + } + } + Option deco = Option.None; List existingItems = []; List playoutTemplates = []; @@ -439,6 +464,7 @@ public class BuildPlayoutHandler : IRequestHandler : IRequestHandler GetProcess(dbContext, request, tuple.Item1, tuple.Item2, tuple.Item3, cancellationToken), + tuple => GetProcess( + dbContext, + request with { Now = request.Now - (tuple.Item1.PlayoutOffset ?? TimeSpan.Zero) }, + tuple.Item1, + tuple.Item2, + tuple.Item3, + cancellationToken), error => Task.FromResult>(error.Join())); } diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs index dc0547631..827a1c835 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs @@ -525,7 +525,15 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); @@ -644,7 +652,8 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); @@ -765,7 +774,8 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs index 12e385acb..cafc96b5a 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs @@ -534,7 +534,15 @@ public class NewPlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); @@ -640,7 +648,15 @@ public class NewPlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); @@ -797,7 +813,15 @@ public class NewPlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); @@ -913,7 +937,15 @@ public class NewPlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); @@ -1028,7 +1060,15 @@ public class NewPlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); @@ -1144,7 +1184,15 @@ public class NewPlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); @@ -1265,7 +1313,15 @@ public class NewPlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); @@ -1386,7 +1442,15 @@ public class NewPlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); @@ -1516,7 +1580,15 @@ public class NewPlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); @@ -1639,7 +1711,15 @@ public class NewPlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); @@ -1728,7 +1808,15 @@ public class NewPlayoutTests : PlayoutBuilderTestBase }; var referenceData = - new PlayoutReferenceData(playout.Channel, Option.None, [], [], playout.ProgramSchedule, [], []); + new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlayoutBuilderTestBase.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlayoutBuilderTestBase.cs index 39f285550..7b3d2710e 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlayoutBuilderTestBase.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlayoutBuilderTestBase.cs @@ -95,7 +95,8 @@ public abstract class PlayoutBuilderTestBase [], playout.ProgramSchedule, [], - []); + [], + TimeSpan.Zero); return new TestData(builder, playout, referenceData); } @@ -208,7 +209,8 @@ public abstract class PlayoutBuilderTestBase [], playout.ProgramSchedule, [], - []); + [], + TimeSpan.Zero); return new TestData(builder, playout, referenceData); } diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/RefreshPlayoutTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/RefreshPlayoutTests.cs index d9b9d2ff6..b96db234c 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/RefreshPlayoutTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/RefreshPlayoutTests.cs @@ -93,7 +93,8 @@ public class RefreshPlayoutTests : PlayoutBuilderTestBase [], playout.ProgramSchedule, [], - []); + [], + TimeSpan.Zero); IConfigElementRepository configRepo = Substitute.For(); var televisionRepo = new FakeTelevisionRepository(); diff --git a/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs b/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs index 987c19542..ebd1958bc 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs @@ -503,6 +503,7 @@ public class ScheduleIntegrationTests playoutTemplates, programSchedule, programScheduleAlternates, - playoutHistory); + playoutHistory, + TimeSpan.Zero); } } diff --git a/ErsatzTV.Core/Scheduling/PlayoutReferenceData.cs b/ErsatzTV.Core/Scheduling/PlayoutReferenceData.cs index 894ea6db0..1cfb37a48 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutReferenceData.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutReferenceData.cs @@ -10,4 +10,5 @@ public record PlayoutReferenceData( List PlayoutTemplates, ProgramSchedule ProgramSchedule, List ProgramScheduleAlternates, - List PlayoutHistory); + List PlayoutHistory, + TimeSpan MinPlayoutOffset); diff --git a/ErsatzTV/Pages/ChannelEditor.razor b/ErsatzTV/Pages/ChannelEditor.razor index 28aa675e5..f52af4285 100644 --- a/ErsatzTV/Pages/ChannelEditor.razor +++ b/ErsatzTV/Pages/ChannelEditor.razor @@ -92,6 +92,15 @@ } + +
+ Playout Offset +
+ +
} else { @@ -376,6 +385,7 @@ else _model.PlayoutSource = channelViewModel.PlayoutSource; _model.PlayoutMode = channelViewModel.PlayoutMode; _model.MirrorSourceChannelId = channelViewModel.MirrorSourceChannelId; + _model.PlayoutOffset = channelViewModel.PlayoutOffset; _model.StreamingMode = channelViewModel.StreamingMode; _model.StreamSelectorMode = channelViewModel.StreamSelectorMode; _model.StreamSelector = channelViewModel.StreamSelector; diff --git a/ErsatzTV/ViewModels/ChannelEditViewModel.cs b/ErsatzTV/ViewModels/ChannelEditViewModel.cs index 8b1ebf1d8..5335aa570 100644 --- a/ErsatzTV/ViewModels/ChannelEditViewModel.cs +++ b/ErsatzTV/ViewModels/ChannelEditViewModel.cs @@ -22,6 +22,14 @@ public class ChannelEditViewModel public ChannelPlayoutSource PlayoutSource { get; set; } public ChannelPlayoutMode PlayoutMode { get; set; } public int? MirrorSourceChannelId { get; set; } + public TimeSpan? PlayoutOffset { get; set; } + + public int PlayoutOffsetHours + { + get => PlayoutOffset?.Hours ?? 0; + set => PlayoutOffset = new TimeSpan(hours: value, minutes: 0, seconds: 0); + } + public StreamingMode StreamingMode { get; set; } public int? WatermarkId { get; set; } public int? FallbackFillerId { get; set; } @@ -61,6 +69,7 @@ public class ChannelEditViewModel PlayoutSource, PlayoutMode, MirrorSourceChannelId, + PlayoutOffset, StreamingMode, WatermarkId, FallbackFillerId, @@ -91,6 +100,7 @@ public class ChannelEditViewModel PlayoutSource, PlayoutMode, MirrorSourceChannelId, + PlayoutOffset, StreamingMode, WatermarkId, FallbackFillerId,