diff --git a/ErsatzTV.Application/Scheduling/Commands/EraseBlockPlayoutHistory.cs b/ErsatzTV.Application/Scheduling/Commands/EraseBlockPlayoutHistory.cs new file mode 100644 index 000000000..c5b35f37f --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Commands/EraseBlockPlayoutHistory.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Scheduling; + +public record EraseBlockPlayoutHistory(int PlayoutId) : IRequest; diff --git a/ErsatzTV.Application/Scheduling/Commands/EraseBlockPlayoutHistoryHandler.cs b/ErsatzTV.Application/Scheduling/Commands/EraseBlockPlayoutHistoryHandler.cs new file mode 100644 index 000000000..664694a9b --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Commands/EraseBlockPlayoutHistoryHandler.cs @@ -0,0 +1,29 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Scheduling; + +public class EraseBlockPlayoutHistoryHandler(IDbContextFactory dbContextFactory) + : IRequestHandler +{ + public async Task Handle(EraseBlockPlayoutHistory request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + Option maybePlayout = await dbContext.Playouts + .Include(p => p.Items) + .Include(p => p.PlayoutHistory) + .Filter(p => p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Block) + .SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId); + + foreach (Playout playout in maybePlayout) + { + playout.Items.Clear(); + playout.PlayoutHistory.Clear(); + + await dbContext.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/ErsatzTV.Core/Scheduling/BlockPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/BlockPlayoutBuilder.cs deleted file mode 100644 index c12fca03a..000000000 --- a/ErsatzTV.Core/Scheduling/BlockPlayoutBuilder.cs +++ /dev/null @@ -1,478 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Domain.Filler; -using ErsatzTV.Core.Domain.Scheduling; -using ErsatzTV.Core.Extensions; -using ErsatzTV.Core.Interfaces.Repositories; -using ErsatzTV.Core.Interfaces.Scheduling; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; - -namespace ErsatzTV.Core.Scheduling; - -public class BlockPlayoutBuilder( - IConfigElementRepository configElementRepository, - IMediaCollectionRepository mediaCollectionRepository, - ITelevisionRepository televisionRepository, - IArtistRepository artistRepository, - ILogger logger) - : IBlockPlayoutBuilder -{ - private static readonly JsonSerializerSettings JsonSettings = new() - { - NullValueHandling = NullValueHandling.Ignore - }; - - public async Task Build(Playout playout, PlayoutBuildMode mode, CancellationToken cancellationToken) - { - logger.LogDebug( - "Building block playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}", - playout.Id, - playout.Channel.Number, - playout.Channel.Name); - - DateTimeOffset start = DateTimeOffset.Now; - DateTimeOffset lastScheduledItem = playout.Items.Count == 0 - ? SystemTime.MinValueUtc - : playout.Items.Max(i => i.StartOffset); - - // get blocks to schedule - List blocksToSchedule = await GetBlocksToSchedule(playout, start); - - // get all collection items for the playout - Map> collectionMediaItems = await GetCollectionMediaItems(blocksToSchedule); - - var itemBlockKeys = new Dictionary(); - foreach (PlayoutItem item in playout.Items) - { - if (!string.IsNullOrWhiteSpace(item.BlockKey)) - { - BlockKey blockKey = JsonConvert.DeserializeObject(item.BlockKey); - itemBlockKeys.Add(item, blockKey); - } - } - - // remove items without a block key (shouldn't happen often, just upgrades) - playout.Items.RemoveAll(i => !itemBlockKeys.ContainsKey(i)); - - // remove playout items with block keys that aren't part of blocksToSchedule - // this could happen if block, template or playout template were updated - foreach ((PlayoutItem item, BlockKey blockKey) in itemBlockKeys) - { - if (blocksToSchedule.All(realBlock => realBlock.BlockKey != blockKey)) - { - logger.LogDebug( - "Removing playout item {Title} with block key {@Key} that is no longer present", - GetDisplayTitle(item), - blockKey); - - playout.Items.Remove(item); - } - } - - foreach (RealBlock realBlock in blocksToSchedule) - { - // skip blocks to schedule that are covered by existing playout items - // this means the block, template and playout template has NOT been updated - // importantly, only skip BEFORE the last scheduled playout item - if (realBlock.Start <= lastScheduledItem && itemBlockKeys.ContainsValue(realBlock.BlockKey)) - { - logger.LogDebug( - "Skipping unchanged block {Block} at {Start}", - realBlock.Block.Name, - realBlock.Start); - - continue; - } - - logger.LogDebug( - "Will schedule block {Block} at {Start}", - realBlock.Block.Name, - realBlock.Start); - - DateTimeOffset currentTime = realBlock.Start; - - foreach (BlockItem blockItem in realBlock.Block.Items) - { - // TODO: support other playback orders - if (blockItem.PlaybackOrder is not PlaybackOrder.SeasonEpisode and not PlaybackOrder.Chronological) - { - continue; - } - - // check for playout history for this collection - string historyKey = HistoryKey.ForBlockItem(blockItem); - //logger.LogDebug("History key for block item {Item} is {Key}", blockItem.Id, historyKey); - - DateTime historyTime = currentTime.UtcDateTime; - Option maybeHistory = playout.PlayoutHistory - .Filter(h => h.BlockId == blockItem.BlockId) - .Filter(h => h.Key == historyKey) - .Filter(h => h.When < historyTime) - .OrderByDescending(h => h.When) - .HeadOrNone(); - - var state = new CollectionEnumeratorState { Seed = 0, Index = 0 }; - - var collectionKey = CollectionKey.ForBlockItem(blockItem); - List collectionItems = collectionMediaItems[collectionKey]; - // get enumerator - var enumerator = new SeasonEpisodeMediaCollectionEnumerator(collectionItems, state); - - // 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); - - // find next media item - HistoryDetails.Details details = JsonConvert.DeserializeObject(history.Details); - if (details.SeasonNumber.HasValue && details.EpisodeNumber.HasValue) - { - Option maybeMatchedItem = Optional( - collectionItems.Find( - ci => ci is Episode e && - e.EpisodeMetadata.Any(em => em.EpisodeNumber == details.EpisodeNumber.Value) && - e.Season.SeasonNumber == details.SeasonNumber.Value)); - - var copy = collectionItems.ToList(); - - if (maybeMatchedItem.IsNone) - { - var fakeItem = new Episode - { - Season = new Season { SeasonNumber = details.SeasonNumber.Value }, - EpisodeMetadata = - [ - new EpisodeMetadata - { - EpisodeNumber = details.EpisodeNumber.Value, - ReleaseDate = details.ReleaseDate - } - ] - }; - - copy.Add(fakeItem); - maybeMatchedItem = fakeItem; - } - - foreach (MediaItem matchedItem in maybeMatchedItem) - { - IComparer comparer = blockItem.PlaybackOrder switch - { - PlaybackOrder.Chronological => new ChronologicalMediaComparer(), - _ => new SeasonEpisodeMediaComparer() - }; - - copy.Sort(comparer); - - state.Index = copy.IndexOf(matchedItem); - enumerator.ResetState(state); - enumerator.MoveNext(); - } - } - } - - foreach (MediaItem mediaItem in enumerator.Current) - { - logger.LogDebug("current item: {Id} / {Title}", mediaItem.Id, mediaItem is Episode e ? GetTitle(e) : string.Empty); - - TimeSpan itemDuration = DurationForMediaItem(mediaItem); - - // create a playout item - var playoutItem = new PlayoutItem - { - MediaItemId = mediaItem.Id, - Start = currentTime.UtcDateTime, - Finish = currentTime.UtcDateTime + itemDuration, - InPoint = TimeSpan.Zero, - OutPoint = itemDuration, - FillerKind = FillerKind.None, - //CustomTitle = scheduleItem.CustomTitle, - //WatermarkId = scheduleItem.WatermarkId, - //PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode, - //PreferredAudioTitle = scheduleItem.PreferredAudioTitle, - //PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, - //SubtitleMode = scheduleItem.SubtitleMode - BlockKey = JsonConvert.SerializeObject(realBlock.BlockKey) - }; - - playout.Items.Add(playoutItem); - - // create a playout history record - var nextHistory = new PlayoutHistory - { - PlayoutId = playout.Id, - BlockId = blockItem.BlockId, - When = currentTime.UtcDateTime, - Key = historyKey, - Details = HistoryDetails.ForMediaItem(mediaItem) - }; - - //logger.LogDebug("Adding history item: {When}: {History}", nextHistory.When, nextHistory.Details); - playout.PlayoutHistory.Add(nextHistory); - - currentTime += itemDuration; - enumerator.MoveNext(); - } - } - } - - CleanUpHistory(playout, start); - - return playout; - } - - private static string GetTitle(Episode e) - { - string showTitle = e.Season.Show.ShowMetadata.HeadOrNone() - .Map(sm => $"{sm.Title} - ").IfNone(string.Empty); - var episodeNumbers = e.EpisodeMetadata.Map(em => em.EpisodeNumber).ToList(); - var episodeTitles = e.EpisodeMetadata.Map(em => em.Title).ToList(); - if (episodeNumbers.Count == 0 || episodeTitles.Count == 0) - { - return "[unknown episode]"; - } - - var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}"; - var titlesString = $"{string.Join('/', episodeTitles)}"; - - return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}"; - } - - private static string GetDisplayTitle(PlayoutItem playoutItem) - { - switch (playoutItem.MediaItem) - { - case Episode e: - string showTitle = e.Season.Show.ShowMetadata.HeadOrNone() - .Map(sm => $"{sm.Title} - ").IfNone(string.Empty); - var episodeNumbers = e.EpisodeMetadata.Map(em => em.EpisodeNumber).ToList(); - var episodeTitles = e.EpisodeMetadata.Map(em => em.Title).ToList(); - if (episodeNumbers.Count == 0 || episodeTitles.Count == 0) - { - return "[unknown episode]"; - } - - var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}"; - var titlesString = $"{string.Join('/', episodeTitles)}"; - if (!string.IsNullOrWhiteSpace(playoutItem.ChapterTitle)) - { - titlesString += $" ({playoutItem.ChapterTitle})"; - } - - return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}"; - case Movie m: - return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"); - case MusicVideo mv: - string artistName = mv.Artist.ArtistMetadata.HeadOrNone() - .Map(am => $"{am.Title} - ").IfNone(string.Empty); - return mv.MusicVideoMetadata.HeadOrNone() - .Map(mvm => $"{artistName}{mvm.Title}") - .Map( - s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) - ? s - : $"{s} ({playoutItem.ChapterTitle})") - .IfNone("[unknown music video]"); - case OtherVideo ov: - return ov.OtherVideoMetadata.HeadOrNone() - .Map(ovm => ovm.Title ?? string.Empty) - .Map( - s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) - ? s - : $"{s} ({playoutItem.ChapterTitle})") - .IfNone("[unknown video]"); - case Song s: - string songArtist = s.SongMetadata.HeadOrNone() - .Map(sm => string.IsNullOrWhiteSpace(sm.Artist) ? string.Empty : $"{sm.Artist} - ") - .IfNone(string.Empty); - return s.SongMetadata.HeadOrNone() - .Map(sm => $"{songArtist}{sm.Title ?? string.Empty}") - .Map( - t => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) - ? t - : $"{s} ({playoutItem.ChapterTitle})") - .IfNone("[unknown song]"); - default: - return string.Empty; - } - } - - private async Task> GetBlocksToSchedule(Playout playout, DateTimeOffset start) - { - int daysToBuild = await configElementRepository.GetValue(ConfigElementKey.PlayoutDaysToBuild) - .IfNoneAsync(2); - - DateTimeOffset finish = start.AddDays(daysToBuild); - - var realBlocks = new List(); - DateTimeOffset current = start.Date; - while (current < finish) - { - foreach (PlayoutTemplate playoutTemplate in PlayoutTemplateSelector.GetPlayoutTemplateFor(playout.Templates, current)) - { - // logger.LogDebug( - // "Will schedule day {Date} using template {Template}", - // current, - // playoutTemplate.Template.Name); - - foreach (TemplateItem templateItem in playoutTemplate.Template.Items) - { - var realBlock = new RealBlock( - templateItem.Block, - new BlockKey(templateItem.Block, templateItem.Template, playoutTemplate), - new DateTimeOffset( - current.Year, - current.Month, - current.Day, - templateItem.StartTime.Hours, - templateItem.StartTime.Minutes, - 0, - start.Offset)); - - realBlocks.Add(realBlock); - } - - current = current.AddDays(1); - } - } - - realBlocks.RemoveAll(b => b.Start.AddMinutes(b.Block.Minutes) < start || b.Start > finish); - realBlocks = realBlocks.OrderBy(rb => rb.Start).ToList(); - - return realBlocks; - } - - private void CleanUpHistory(Playout playout, DateTimeOffset start) - { - var groups = new Dictionary>(); - foreach (PlayoutHistory history in playout.PlayoutHistory) - { - var key = $"{history.BlockId}-{history.Key}"; - if (!groups.TryGetValue(key, out List group)) - { - group = []; - groups[key] = group; - } - - group.Add(history); - } - - foreach ((string key, List group) in groups) - { - //logger.LogDebug("History key {Key} has {Count} items in group", key, group.Count); - - IEnumerable toDelete = group - .Filter(h => h.When < start.UtcDateTime) - .OrderByDescending(h => h.When) - .Tail(); - - foreach (PlayoutHistory delete in toDelete) - { - playout.PlayoutHistory.Remove(delete); - } - } - } - - private async Task>> GetCollectionMediaItems(List realBlocks) - { - var collectionKeys = realBlocks.Map(b => b.Block.Items) - .Flatten() - .DistinctBy(i => i.Id) - .Map(CollectionKey.ForBlockItem) - .Distinct() - .ToList(); - - IEnumerable>> tuples = await collectionKeys.Map( - async collectionKey => Tuple( - collectionKey, - await MediaItemsForCollection.Collect( - mediaCollectionRepository, - televisionRepository, - artistRepository, - collectionKey))).SequenceParallel(); - - return LanguageExt.Map.createRange(tuples); - } - - private static TimeSpan DurationForMediaItem(MediaItem mediaItem) - { - MediaVersion version = mediaItem.GetHeadVersion(); - return version.Duration; - } - - private record RealBlock(Block Block, BlockKey BlockKey, DateTimeOffset Start); - - [SuppressMessage("ReSharper", "InconsistentNaming")] - private record BlockKey - { - public BlockKey() - { - } - - public BlockKey(Block block, Template template, PlayoutTemplate playoutTemplate) - { - b = block.Id; - bt = block.DateUpdated.Ticks; - t = template.Id; - tt = template.DateUpdated.Ticks; - pt = playoutTemplate.Id; - ptt = playoutTemplate.DateUpdated.Ticks; - } - - public int b { get; set; } - public int t { get; set; } - public int pt { get; set; } - - public long bt { get; set; } - public long tt { get; set; } - public long ptt { get; set; } - } - - private static class HistoryKey - { - public static string ForBlockItem(BlockItem blockItem) - { - dynamic key = new - { - blockItem.BlockId, - blockItem.PlaybackOrder, - blockItem.CollectionType, - blockItem.CollectionId, - blockItem.MultiCollectionId, - blockItem.SmartCollectionId, - blockItem.MediaItemId - }; - - return JsonConvert.SerializeObject(key, Formatting.None, JsonSettings); - } - } - - private static class HistoryDetails - { - public static string ForMediaItem(MediaItem mediaItem) - { - Details details = mediaItem switch - { - Episode e => ForEpisode(e), - _ => new Details(mediaItem.Id, null, null, null) - }; - - return JsonConvert.SerializeObject(details, Formatting.None, JsonSettings); - } - - private static Details ForEpisode(Episode e) - { - int? episodeNumber = null; - DateTime? releaseDate = null; - foreach (EpisodeMetadata episodeMetadata in e.EpisodeMetadata.HeadOrNone()) - { - episodeNumber = episodeMetadata.EpisodeNumber; - releaseDate = episodeMetadata.ReleaseDate; - } - - return new Details(e.Id, releaseDate, e.Season.SeasonNumber, episodeNumber); - } - - public record Details(int? MediaItemId, DateTime? ReleaseDate, int? SeasonNumber, int? EpisodeNumber); - } -} diff --git a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockKey.cs b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockKey.cs new file mode 100644 index 000000000..5632aaed1 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockKey.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; +using ErsatzTV.Core.Domain.Scheduling; + +namespace ErsatzTV.Core.Scheduling.BlockScheduling; + +[SuppressMessage("ReSharper", "InconsistentNaming")] +public record BlockKey +{ + public BlockKey() + { + } + + public BlockKey(Block block, Template template, PlayoutTemplate playoutTemplate) + { + b = block.Id; + bt = block.DateUpdated.Ticks; + t = template.Id; + tt = template.DateUpdated.Ticks; + pt = playoutTemplate.Id; + ptt = playoutTemplate.DateUpdated.Ticks; + } + + /// + /// Block Id + /// + public int b { get; set; } + + /// + /// Template Id + /// + public int t { get; set; } + + /// + /// Playout Template Id + /// + public int pt { get; set; } + + /// + /// Block Date Updated Ticks + /// + public long bt { get; set; } + + /// + /// Template Date Updated Ticks + /// + public long tt { get; set; } + + /// + /// Playout Template Date Updated Ticks + /// + public long ptt { get; set; } +} diff --git a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs new file mode 100644 index 000000000..50883bd66 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs @@ -0,0 +1,270 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Scheduling; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace ErsatzTV.Core.Scheduling.BlockScheduling; + +public class BlockPlayoutBuilder( + IConfigElementRepository configElementRepository, + IMediaCollectionRepository mediaCollectionRepository, + ITelevisionRepository televisionRepository, + IArtistRepository artistRepository, + ILogger logger) + : IBlockPlayoutBuilder +{ + public async Task Build(Playout playout, PlayoutBuildMode mode, CancellationToken cancellationToken) + { + logger.LogDebug( + "Building block playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}", + playout.Id, + playout.Channel.Number, + playout.Channel.Name); + + DateTimeOffset start = DateTimeOffset.Now; + + int daysToBuild = await configElementRepository.GetValue(ConfigElementKey.PlayoutDaysToBuild) + .IfNoneAsync(2); + + // get blocks to schedule + List blocksToSchedule = EffectiveBlock.GetEffectiveBlocks(playout, start, daysToBuild); + + // get all collection items for the playout + Map> collectionMediaItems = await GetCollectionMediaItems(blocksToSchedule); + + Dictionary itemBlockKeys = BlockPlayoutChangeDetection.GetPlayoutItemToBlockKeyMap(playout); + + // remove items without a block key (shouldn't happen often, just upgrades) + playout.Items.RemoveAll(i => !itemBlockKeys.ContainsKey(i)); + + (List updatedEffectiveBlocks, List playoutItemsToRemove) = + BlockPlayoutChangeDetection.FindUpdatedItems(playout, itemBlockKeys, blocksToSchedule); + + foreach (PlayoutItem playoutItem in playoutItemsToRemove) + { + BlockPlayoutChangeDetection.RemoveItemAndHistory(playout, playoutItem); + } + + foreach (EffectiveBlock effectiveBlock in updatedEffectiveBlocks) + { + logger.LogDebug( + "Will schedule block {Block} at {Start}", + effectiveBlock.Block.Name, + effectiveBlock.Start); + + DateTimeOffset currentTime = effectiveBlock.Start; + + foreach (BlockItem blockItem in effectiveBlock.Block.Items) + { + // TODO: support other playback orders + if (blockItem.PlaybackOrder is not PlaybackOrder.SeasonEpisode and not PlaybackOrder.Chronological) + { + continue; + } + + // check for playout history for this collection + string historyKey = HistoryDetails.KeyForBlockItem(blockItem); + //logger.LogDebug("History key for block item {Item} is {Key}", blockItem.Id, historyKey); + + DateTime historyTime = currentTime.UtcDateTime; + Option maybeHistory = playout.PlayoutHistory + .Filter(h => h.BlockId == blockItem.BlockId) + .Filter(h => h.Key == historyKey) + .Filter(h => h.When < historyTime) + .OrderByDescending(h => h.When) + .HeadOrNone(); + + var state = new CollectionEnumeratorState { Seed = 0, Index = 0 }; + + var collectionKey = CollectionKey.ForBlockItem(blockItem); + List collectionItems = collectionMediaItems[collectionKey]; + // get enumerator + var enumerator = new SeasonEpisodeMediaCollectionEnumerator(collectionItems, state); + + // 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); + + // find next media item + HistoryDetails.Details details = + JsonConvert.DeserializeObject(history.Details); + if (details.SeasonNumber.HasValue && details.EpisodeNumber.HasValue) + { + Option maybeMatchedItem = Optional( + collectionItems.Find( + ci => ci is Episode e && + e.EpisodeMetadata.Any(em => em.EpisodeNumber == details.EpisodeNumber.Value) && + e.Season.SeasonNumber == details.SeasonNumber.Value)); + + var copy = collectionItems.ToList(); + + if (maybeMatchedItem.IsNone) + { + var fakeItem = new Episode + { + Season = new Season { SeasonNumber = details.SeasonNumber.Value }, + EpisodeMetadata = + [ + new EpisodeMetadata + { + EpisodeNumber = details.EpisodeNumber.Value, + ReleaseDate = details.ReleaseDate + } + ] + }; + + copy.Add(fakeItem); + maybeMatchedItem = fakeItem; + } + + foreach (MediaItem matchedItem in maybeMatchedItem) + { + IComparer comparer = blockItem.PlaybackOrder switch + { + PlaybackOrder.Chronological => new ChronologicalMediaComparer(), + _ => new SeasonEpisodeMediaComparer() + }; + + copy.Sort(comparer); + + state.Index = copy.IndexOf(matchedItem); + enumerator.ResetState(state); + enumerator.MoveNext(); + } + } + } + + foreach (MediaItem mediaItem in enumerator.Current) + { + logger.LogDebug( + "current item: {Id} / {Title}", + mediaItem.Id, + mediaItem is Episode e ? GetTitle(e) : string.Empty); + + TimeSpan itemDuration = DurationForMediaItem(mediaItem); + + // create a playout item + var playoutItem = new PlayoutItem + { + MediaItemId = mediaItem.Id, + Start = currentTime.UtcDateTime, + Finish = currentTime.UtcDateTime + itemDuration, + InPoint = TimeSpan.Zero, + OutPoint = itemDuration, + FillerKind = FillerKind.None, + //CustomTitle = scheduleItem.CustomTitle, + //WatermarkId = scheduleItem.WatermarkId, + //PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode, + //PreferredAudioTitle = scheduleItem.PreferredAudioTitle, + //PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, + //SubtitleMode = scheduleItem.SubtitleMode + BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey) + }; + + playout.Items.Add(playoutItem); + + // create a playout history record + var nextHistory = new PlayoutHistory + { + PlayoutId = playout.Id, + BlockId = blockItem.BlockId, + When = currentTime.UtcDateTime, + Key = historyKey, + Details = HistoryDetails.ForMediaItem(mediaItem) + }; + + //logger.LogDebug("Adding history item: {When}: {History}", nextHistory.When, nextHistory.Details); + playout.PlayoutHistory.Add(nextHistory); + + currentTime += itemDuration; + enumerator.MoveNext(); + } + } + } + + CleanUpHistory(playout, start); + + return playout; + } + + private static string GetTitle(Episode e) + { + string showTitle = e.Season.Show.ShowMetadata.HeadOrNone() + .Map(sm => $"{sm.Title} - ").IfNone(string.Empty); + var episodeNumbers = e.EpisodeMetadata.Map(em => em.EpisodeNumber).ToList(); + var episodeTitles = e.EpisodeMetadata.Map(em => em.Title).ToList(); + if (episodeNumbers.Count == 0 || episodeTitles.Count == 0) + { + return "[unknown episode]"; + } + + var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}"; + var titlesString = $"{string.Join('/', episodeTitles)}"; + + return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}"; + } + + private static void CleanUpHistory(Playout playout, DateTimeOffset start) + { + var groups = new Dictionary>(); + foreach (PlayoutHistory history in playout.PlayoutHistory) + { + var key = $"{history.BlockId}-{history.Key}"; + if (!groups.TryGetValue(key, out List group)) + { + group = []; + groups[key] = group; + } + + group.Add(history); + } + + foreach ((string key, List group) in groups) + { + //logger.LogDebug("History key {Key} has {Count} items in group", key, group.Count); + + IEnumerable toDelete = group + .Filter(h => h.When < start.UtcDateTime) + .OrderByDescending(h => h.When) + .Tail(); + + foreach (PlayoutHistory delete in toDelete) + { + playout.PlayoutHistory.Remove(delete); + } + } + } + + private async Task>> GetCollectionMediaItems( + List effectiveBlocks) + { + var collectionKeys = effectiveBlocks.Map(b => b.Block.Items) + .Flatten() + .DistinctBy(i => i.Id) + .Map(CollectionKey.ForBlockItem) + .Distinct() + .ToList(); + + IEnumerable>> tuples = await collectionKeys.Map( + async collectionKey => Tuple( + collectionKey, + await MediaItemsForCollection.Collect( + mediaCollectionRepository, + televisionRepository, + artistRepository, + collectionKey))).SequenceParallel(); + + return LanguageExt.Map.createRange(tuples); + } + + private static TimeSpan DurationForMediaItem(MediaItem mediaItem) + { + MediaVersion version = mediaItem.GetHeadVersion(); + return version.Duration; + } +} diff --git a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutChangeDetection.cs b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutChangeDetection.cs new file mode 100644 index 000000000..d82f895d6 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutChangeDetection.cs @@ -0,0 +1,114 @@ +using System.Collections.Immutable; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using Newtonsoft.Json; + +namespace ErsatzTV.Core.Scheduling.BlockScheduling; + +internal static class BlockPlayoutChangeDetection +{ + public static Dictionary GetPlayoutItemToBlockKeyMap(Playout playout) + { + var itemBlockKeys = new Dictionary(); + foreach (PlayoutItem item in playout.Items) + { + if (!string.IsNullOrWhiteSpace(item.BlockKey)) + { + BlockKey blockKey = JsonConvert.DeserializeObject(item.BlockKey); + itemBlockKeys.Add(item, blockKey); + } + } + + return itemBlockKeys.ToDictionary(); + } + + public static Tuple, List> FindUpdatedItems( + Playout playout, + Dictionary itemBlockKeys, + List blocksToSchedule) + { + DateTimeOffset lastScheduledItem = playout.Items.Count == 0 + ? SystemTime.MinValueUtc + : playout.Items.Max(i => i.StartOffset); + + var existingBlockKeys = itemBlockKeys.Values.ToImmutableHashSet(); + var blockKeysToSchedule = blocksToSchedule.Map(b => b.BlockKey).ToImmutableHashSet(); + var updatedBlocks = new List(); + var updatedItems = new List(); + + var earliestEffectiveBlocks = new Dictionary(); + var earliestBlocks = new Dictionary(); + + // process in sorted order to simplify checks + foreach (EffectiveBlock effectiveBlock in blocksToSchedule.OrderBy(b => b.Start)) + { + // future blocks always need to be scheduled + if (effectiveBlock.Start > lastScheduledItem) + { + updatedBlocks.Add(effectiveBlock); + } + // if block key is not present in existingBlockKeys, the effective block is new or updated + else if (!existingBlockKeys.Contains(effectiveBlock.BlockKey)) + { + updatedBlocks.Add(effectiveBlock); + + if (!earliestEffectiveBlocks.ContainsKey(effectiveBlock.BlockKey)) + { + earliestEffectiveBlocks[effectiveBlock.BlockKey] = effectiveBlock.Start; + } + + if (!earliestBlocks.ContainsKey(effectiveBlock.Block.Id)) + { + earliestBlocks[effectiveBlock.Block.Id] = effectiveBlock.Start; + } + } + // if id is present, the block has been modified earlier, so this effective block also needs to update + else if (earliestBlocks.ContainsKey(effectiveBlock.Block.Id)) + { + updatedBlocks.Add(effectiveBlock); + } + } + + foreach ((BlockKey key, DateTimeOffset value) in earliestEffectiveBlocks) + { + Serilog.Log.Logger.Debug("Earliest effective block: {Key} => {Value}", key, value); + } + + foreach ((int blockId, DateTimeOffset value) in earliestBlocks) + { + Serilog.Log.Logger.Debug("Earliest block id: {Id} => {Value}", blockId, value); + } + + // find affected playout items + foreach (PlayoutItem item in playout.Items) + { + BlockKey blockKey = itemBlockKeys[item]; + + bool blockKeyIsAffected = earliestEffectiveBlocks.TryGetValue(blockKey, out DateTimeOffset value) && + value <= item.StartOffset; + + bool blockIdIsAffected = earliestBlocks.TryGetValue(blockKey.b, out DateTimeOffset value2) && + value2 <= item.StartOffset; + + if (!blockKeysToSchedule.Contains(blockKey) || blockKeyIsAffected || blockIdIsAffected) + { + updatedItems.Add(item); + } + } + + return Tuple(updatedBlocks, updatedItems); + } + + public static void RemoveItemAndHistory(Playout playout, PlayoutItem playoutItem) + { + playout.Items.Remove(playoutItem); + + Option historyToRemove = playout.PlayoutHistory + .Find(h => h.When == playoutItem.Start); + + foreach (PlayoutHistory history in historyToRemove) + { + playout.PlayoutHistory.Remove(history); + } + } +} diff --git a/ErsatzTV.Core/Scheduling/BlockScheduling/EffectiveBlock.cs b/ErsatzTV.Core/Scheduling/BlockScheduling/EffectiveBlock.cs new file mode 100644 index 000000000..c0e6baf45 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/BlockScheduling/EffectiveBlock.cs @@ -0,0 +1,51 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; + +namespace ErsatzTV.Core.Scheduling.BlockScheduling; + +internal record EffectiveBlock(Block Block, BlockKey BlockKey, DateTimeOffset Start) +{ + public static List GetEffectiveBlocks(Playout playout, DateTimeOffset start, int daysToBuild) + { + DateTimeOffset finish = start.AddDays(daysToBuild); + + var effectiveBlocks = new List(); + DateTimeOffset current = start.Date; + while (current < finish) + { + foreach (PlayoutTemplate playoutTemplate in PlayoutTemplateSelector.GetPlayoutTemplateFor( + playout.Templates, + current)) + { + // logger.LogDebug( + // "Will schedule day {Date} using template {Template}", + // current, + // playoutTemplate.Template.Name); + + foreach (TemplateItem templateItem in playoutTemplate.Template.Items) + { + var effectiveBlock = new EffectiveBlock( + templateItem.Block, + new BlockKey(templateItem.Block, templateItem.Template, playoutTemplate), + new DateTimeOffset( + current.Year, + current.Month, + current.Day, + templateItem.StartTime.Hours, + templateItem.StartTime.Minutes, + 0, + start.Offset)); + + effectiveBlocks.Add(effectiveBlock); + } + + current = current.AddDays(1); + } + } + + effectiveBlocks.RemoveAll(b => b.Start.AddMinutes(b.Block.Minutes) < start || b.Start > finish); + effectiveBlocks = effectiveBlocks.OrderBy(rb => rb.Start).ToList(); + + return effectiveBlocks; + } +} diff --git a/ErsatzTV.Core/Scheduling/BlockScheduling/HistoryDetails.cs b/ErsatzTV.Core/Scheduling/BlockScheduling/HistoryDetails.cs new file mode 100644 index 000000000..64929365f --- /dev/null +++ b/ErsatzTV.Core/Scheduling/BlockScheduling/HistoryDetails.cs @@ -0,0 +1,55 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using Newtonsoft.Json; + +namespace ErsatzTV.Core.Scheduling.BlockScheduling; + +internal static class HistoryDetails +{ + private static readonly JsonSerializerSettings JsonSettings = new() + { + NullValueHandling = NullValueHandling.Ignore + }; + + public static string KeyForBlockItem(BlockItem blockItem) + { + dynamic key = new + { + blockItem.BlockId, + blockItem.PlaybackOrder, + blockItem.CollectionType, + blockItem.CollectionId, + blockItem.MultiCollectionId, + blockItem.SmartCollectionId, + blockItem.MediaItemId + }; + + return JsonConvert.SerializeObject(key, Formatting.None, JsonSettings); + } + + public static string ForMediaItem(MediaItem mediaItem) + { + Details details = mediaItem switch + { + Episode e => ForEpisode(e), + _ => new Details(mediaItem.Id, null, null, null) + }; + + return JsonConvert.SerializeObject(details, Formatting.None, JsonSettings); + } + + private static Details ForEpisode(Episode e) + { + int? episodeNumber = null; + DateTime? releaseDate = null; + foreach (EpisodeMetadata episodeMetadata in e.EpisodeMetadata.HeadOrNone()) + { + episodeNumber = episodeMetadata.EpisodeNumber; + releaseDate = episodeMetadata.ReleaseDate; + } + + return new Details(e.Id, releaseDate, e.Season.SeasonNumber, episodeNumber); + } + + public record Details(int? MediaItemId, DateTime? ReleaseDate, int? SeasonNumber, int? EpisodeNumber); +} diff --git a/ErsatzTV/Pages/Playouts.razor b/ErsatzTV/Pages/Playouts.razor index 40f274580..5462d47f2 100644 --- a/ErsatzTV/Pages/Playouts.razor +++ b/ErsatzTV/Pages/Playouts.razor @@ -1,6 +1,7 @@ @page "/playouts" @using ErsatzTV.Application.Playouts @using ErsatzTV.Application.Configuration +@using ErsatzTV.Application.Scheduling @using ErsatzTV.Core.Scheduling @implements IDisposable @inject IDialogService Dialog @@ -109,7 +110,6 @@ } else if (context.PlayoutType == ProgramSchedulePlayoutType.Block) { -
+ + + + } @foreach (BlockGroupViewModel blockGroup in _blockGroups) { - - @blockGroup.Name - + @blockGroup.Name } + +
+ + + + (none) + @foreach (TemplateItemEditViewModel item in _template.Items.OrderBy(i => i.Start)) + { + @item.Start.ToShortTimeString() - @item.Text + } + + + + + Remove Block From Template + + + +
+
- + @item.Text diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 806f72bb3..de0c75c15 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -33,6 +33,7 @@ using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Plex; using ErsatzTV.Core.Scheduling; +using ErsatzTV.Core.Scheduling.BlockScheduling; using ErsatzTV.Core.Trakt; using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.FFmpeg.Pipeline;