diff --git a/ErsatzTV.Application/Playouts/Mapper.cs b/ErsatzTV.Application/Playouts/Mapper.cs index 10f176d1d..f336e22ba 100644 --- a/ErsatzTV.Application/Playouts/Mapper.cs +++ b/ErsatzTV.Application/Playouts/Mapper.cs @@ -22,7 +22,7 @@ internal static class Mapper programScheduleAlternate.DaysOfMonth, programScheduleAlternate.MonthsOfYear); - private static string GetDisplayTitle(PlayoutItem playoutItem) + internal static string GetDisplayTitle(PlayoutItem playoutItem) { switch (playoutItem.MediaItem) { @@ -80,7 +80,7 @@ internal static class Mapper } } - private static string GetDisplayDuration(TimeSpan duration) => + internal static string GetDisplayDuration(TimeSpan duration) => string.Format( CultureInfo.InvariantCulture, duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}", diff --git a/ErsatzTV.Application/Scheduling/Commands/PreviewBlockPlayout.cs b/ErsatzTV.Application/Scheduling/Commands/PreviewBlockPlayout.cs new file mode 100644 index 000000000..89eb7ad11 --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Commands/PreviewBlockPlayout.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Scheduling; + +public record PreviewBlockPlayout(ReplaceBlockItems Data) : IRequest>; diff --git a/ErsatzTV.Application/Scheduling/Commands/PreviewBlockPlayoutHandler.cs b/ErsatzTV.Application/Scheduling/Commands/PreviewBlockPlayoutHandler.cs new file mode 100644 index 000000000..db4cdab72 --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Commands/PreviewBlockPlayoutHandler.cs @@ -0,0 +1,118 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ErsatzTV.Application.Scheduling; + +public class PreviewBlockPlayoutHandler( + IDbContextFactory dbContextFactory, + IBlockPlayoutBuilder blockPlayoutBuilder) + : IRequestHandler> +{ + public async Task> Handle( + PreviewBlockPlayout request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + var template = new Template + { + Items = [] + }; + + template.Items.Add( + new TemplateItem + { + Block = MapToBlock(request.Data), + StartTime = TimeSpan.Zero, + Template = template + }); + + var playout = new Playout + { + Channel = new Channel(Guid.NewGuid()) + { + Number = "1", + Name = "Block Preview" + }, + Items = [], + ProgramSchedulePlayoutType = ProgramSchedulePlayoutType.Block, + PlayoutHistory = [], + Templates = + [ + new PlayoutTemplate + { + DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(), + DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(), + MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(), + Template = template + } + ] + }; + + await blockPlayoutBuilder.Build( + playout, + PlayoutBuildMode.Reset, + NullLogger.Instance, + 1, + randomizeStartPoints: true, + cancellationToken); + + // load playout item details for title + foreach (PlayoutItem playoutItem in playout.Items) + { + Option maybeMediaItem = await dbContext.MediaItems + .AsNoTracking() + .Include(mi => (mi as Movie).MovieMetadata) + .Include(mi => (mi as Movie).MediaVersions) + .Include(mi => (mi as MusicVideo).MusicVideoMetadata) + .Include(mi => (mi as MusicVideo).MediaVersions) + .Include(mi => (mi as MusicVideo).Artist) + .ThenInclude(mm => mm.ArtistMetadata) + .Include(mi => (mi as Episode).EpisodeMetadata) + .Include(mi => (mi as Episode).MediaVersions) + .Include(mi => (mi as Episode).Season) + .ThenInclude(s => s.SeasonMetadata) + .Include(mi => (mi as Episode).Season.Show) + .ThenInclude(s => s.ShowMetadata) + .Include(mi => (mi as OtherVideo).OtherVideoMetadata) + .Include(mi => (mi as OtherVideo).MediaVersions) + .Include(mi => (mi as Song).SongMetadata) + .Include(mi => (mi as Song).MediaVersions) + .SelectOneAsync(mi => mi.Id, mi => mi.Id == playoutItem.MediaItemId); + + foreach (MediaItem mediaItem in maybeMediaItem) + { + playoutItem.MediaItem = mediaItem; + } + } + + return playout.Items.Map(Mapper.ProjectToViewModel).ToList(); + } + + private static Block MapToBlock(ReplaceBlockItems request) => + new() + { + Minutes = request.Minutes, + Name = request.Name, + Items = request.Items.Map(MapToBlockItem).ToList(), + }; + + private static BlockItem MapToBlockItem(int id, ReplaceBlockItem request) => + new() + { + Id = id, + Index = request.Index, + CollectionType = request.CollectionType, + CollectionId = request.CollectionId, + MultiCollectionId = request.MultiCollectionId, + SmartCollectionId = request.SmartCollectionId, + MediaItemId = request.MediaItemId, + PlaybackOrder = request.PlaybackOrder + }; +} diff --git a/ErsatzTV.Application/Scheduling/Mapper.cs b/ErsatzTV.Application/Scheduling/Mapper.cs index eff9ad359..fd51c288e 100644 --- a/ErsatzTV.Application/Scheduling/Mapper.cs +++ b/ErsatzTV.Application/Scheduling/Mapper.cs @@ -53,4 +53,11 @@ internal static class Mapper playoutTemplate.DaysOfWeek, playoutTemplate.DaysOfMonth, playoutTemplate.MonthsOfYear); + + internal static PlayoutItemPreviewViewModel ProjectToViewModel(PlayoutItem playoutItem) => + new( + Playouts.Mapper.GetDisplayTitle(playoutItem), + playoutItem.StartOffset.TimeOfDay, + playoutItem.FinishOffset.TimeOfDay, + Playouts.Mapper.GetDisplayDuration(playoutItem.FinishOffset - playoutItem.StartOffset)); } diff --git a/ErsatzTV.Application/Scheduling/PlayoutItemPreviewViewModel.cs b/ErsatzTV.Application/Scheduling/PlayoutItemPreviewViewModel.cs new file mode 100644 index 000000000..1807f17e8 --- /dev/null +++ b/ErsatzTV.Application/Scheduling/PlayoutItemPreviewViewModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Scheduling; + +public record PlayoutItemPreviewViewModel(string Title, TimeSpan Start, TimeSpan Finish, string Duration); diff --git a/ErsatzTV.Core/Interfaces/Scheduling/IBlockPlayoutBuilder.cs b/ErsatzTV.Core/Interfaces/Scheduling/IBlockPlayoutBuilder.cs index 8b65af361..468541aba 100644 --- a/ErsatzTV.Core/Interfaces/Scheduling/IBlockPlayoutBuilder.cs +++ b/ErsatzTV.Core/Interfaces/Scheduling/IBlockPlayoutBuilder.cs @@ -1,9 +1,18 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Scheduling; +using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.Interfaces.Scheduling; public interface IBlockPlayoutBuilder { Task Build(Playout playout, PlayoutBuildMode mode, CancellationToken cancellationToken); + + Task Build( + Playout playout, + PlayoutBuildMode mode, + ILogger customLogger, + int daysToBuild, + bool randomizeStartPoints, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs index 4fc038233..bdf279b64 100644 --- a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs @@ -19,7 +19,24 @@ public class BlockPlayoutBuilder( { public async Task Build(Playout playout, PlayoutBuildMode mode, CancellationToken cancellationToken) { - logger.LogDebug( + int daysToBuild = await configElementRepository.GetValue(ConfigElementKey.PlayoutDaysToBuild) + .IfNoneAsync(2); + + return await Build(playout, mode, logger, daysToBuild, randomizeStartPoints: false, cancellationToken); + } + + public async Task Build( + Playout playout, + PlayoutBuildMode mode, + ILogger customLogger, + int daysToBuild, + bool randomizeStartPoints, + CancellationToken cancellationToken) + { + // ReSharper disable once LocalVariableHidesPrimaryConstructorParameter + ILogger log = customLogger ?? logger; + + log.LogDebug( "Building block playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}", playout.Id, playout.Channel.Number, @@ -34,8 +51,6 @@ public class BlockPlayoutBuilder( DateTimeOffset start = DateTimeOffset.Now; - int daysToBuild = await configElementRepository.GetValue(ConfigElementKey.PlayoutDaysToBuild) - .IfNoneAsync(2); // get blocks to schedule List blocksToSchedule = EffectiveBlock.GetEffectiveBlocks(playout, start, daysToBuild); @@ -43,8 +58,9 @@ public class BlockPlayoutBuilder( // get all collection items for the playout Map> collectionMediaItems = await GetCollectionMediaItems(blocksToSchedule); - Dictionary itemBlockKeys = BlockPlayoutChangeDetection.GetPlayoutItemToBlockKeyMap(playout); - + Dictionary itemBlockKeys = + BlockPlayoutChangeDetection.GetPlayoutItemToBlockKeyMap(playout); + // remove items without a block key (shouldn't happen often, just upgrades) playout.Items.RemoveAll(i => !itemBlockKeys.ContainsKey(i)); @@ -64,14 +80,14 @@ public class BlockPlayoutBuilder( { currentTime = effectiveBlock.Start; - logger.LogDebug( + log.LogDebug( "Will schedule block {Block} at {Start}", effectiveBlock.Block.Name, effectiveBlock.Start); } else { - logger.LogDebug( + log.LogDebug( "Will schedule block {Block} with start {Start} at {ActualStart}", effectiveBlock.Block.Name, effectiveBlock.Start, @@ -90,7 +106,7 @@ public class BlockPlayoutBuilder( if (currentTime >= blockFinish) { - logger.LogDebug( + log.LogDebug( "Current time {Time} for block {Block} is beyond block finish {Finish}; will stop with this block's items", currentTime, effectiveBlock.Block.Name, @@ -129,7 +145,7 @@ public class BlockPlayoutBuilder( // seek to the appropriate place in the collection enumerator foreach (PlayoutHistory history in maybeHistory) { - logger.LogDebug("History is applicable: {When}: {History}", history.When, history.Details); + log.LogDebug("History is applicable: {When}: {History}", history.When, history.Details); HistoryDetails.MoveToNextItem( collectionItems, @@ -138,9 +154,19 @@ public class BlockPlayoutBuilder( blockItem.PlaybackOrder); } + if (maybeHistory.IsNone && randomizeStartPoints) + { + enumerator.ResetState( + new CollectionEnumeratorState + { + Seed = new Random().Next(), + Index = new Random().Next(collectionItems.Count) + }); + } + foreach (MediaItem mediaItem in enumerator.Current) { - logger.LogDebug( + log.LogDebug( "current item: {Id} / {Title}", mediaItem.Id, mediaItem is Episode e ? GetTitle(e) : string.Empty); @@ -223,7 +249,7 @@ public class BlockPlayoutBuilder( group.Add(history); } - foreach ((string key, List group) in groups) + foreach ((string _, List group) in groups) { //logger.LogDebug("History key {Key} has {Count} items in group", key, group.Count); diff --git a/ErsatzTV/Pages/BlockEditor.razor b/ErsatzTV/Pages/BlockEditor.razor index 39fa50efe..7f9fc020e 100644 --- a/ErsatzTV/Pages/BlockEditor.razor +++ b/ErsatzTV/Pages/BlockEditor.razor @@ -25,67 +25,80 @@ - - - - + + + + - - - - - - - - - Collection - - - - - - - - @context.CollectionName - - - - - - - - - - - - - - - - Add Block Item Save Changes - - @if (_selectedItem is not null) - { - - -
-
+ + Preview Block Playout + + + + + + + + + + + + + Collection + Playback Order + + + + + + + + @context.CollectionName + + + + + @context.PlaybackOrder + + + + + + + + + + + + + + + + + + +
+ @if (_selectedItem is not null) + { + + +
@@ -108,6 +121,7 @@ } + @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.MultiCollection) { } + @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.SmartCollection) { } + @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow) { } + @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionSeason) { } + @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Artist) { } + @switch (_selectedItem.CollectionType) { @@ -204,8 +223,31 @@
-
- + + } +
+ @if (_previewItems != null) + { + + + Block Playout Preview + + + Start + Finish + Media Item + Duration + + + @context.Start.ToString(@"hh\:mm\:ss") + @context.Finish.ToString(@"hh\:mm\:ss") + @context.Title + @context.Duration + + } @@ -217,6 +259,7 @@ private BlockItemsEditViewModel _block = new() { Items = [] }; private BlockItemEditViewModel _selectedItem; + private List _previewItems; private int _durationHours = 0; private int _durationMinutes = 15; @@ -368,6 +411,21 @@ } private async Task SaveChanges() + { + Seq errorMessages = await Mediator + .Send(GenerateReplaceRequest(), _cts.Token) + .Map(e => e.LeftToSeq()); + + errorMessages.HeadOrNone().Match( + error => + { + Snackbar.Add($"Unexpected error saving block: {error.Value}", Severity.Error); + Logger.LogError("Unexpected error saving block: {Error}", error.Value); + }, + () => NavigationManager.NavigateTo("/blocks")); + } + + private ReplaceBlockItems GenerateReplaceRequest() { var items = _block.Items.Map( item => new ReplaceBlockItem( @@ -381,16 +439,12 @@ _block.Minutes = _durationHours * 60 + _durationMinutes; - Seq errorMessages = await Mediator - .Send(new ReplaceBlockItems(Id, _block.Name, _block.Minutes, items), _cts.Token) - .Map(e => e.LeftToSeq()); + return new ReplaceBlockItems(Id, _block.Name, _block.Minutes, items); + } - errorMessages.HeadOrNone().Match( - error => - { - Snackbar.Add($"Unexpected error saving block: {error.Value}", Severity.Error); - Logger.LogError("Unexpected error saving block: {Error}", error.Value); - }, - () => NavigationManager.NavigateTo("/blocks")); + private async Task PreviewPlayout() + { + _selectedItem = null; + _previewItems = await Mediator.Send(new PreviewBlockPlayout(GenerateReplaceRequest()), _cts.Token); } -} \ No newline at end of file +}