mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* erase block playout history and items from playouts page * remove block from template * refactor block scheduling; improve history behaviorpull/1554/head
12 changed files with 632 additions and 483 deletions
@ -0,0 +1,3 @@ |
|||||||
|
namespace ErsatzTV.Application.Scheduling; |
||||||
|
|
||||||
|
public record EraseBlockPlayoutHistory(int PlayoutId) : IRequest; |
||||||
@ -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<TvContext> dbContextFactory) |
||||||
|
: IRequestHandler<EraseBlockPlayoutHistory> |
||||||
|
{ |
||||||
|
public async Task Handle(EraseBlockPlayoutHistory request, CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||||
|
|
||||||
|
Option<Playout> 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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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<BlockPlayoutBuilder> logger) |
|
||||||
: IBlockPlayoutBuilder |
|
||||||
{ |
|
||||||
private static readonly JsonSerializerSettings JsonSettings = new() |
|
||||||
{ |
|
||||||
NullValueHandling = NullValueHandling.Ignore |
|
||||||
}; |
|
||||||
|
|
||||||
public async Task<Playout> 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<RealBlock> blocksToSchedule = await GetBlocksToSchedule(playout, start); |
|
||||||
|
|
||||||
// get all collection items for the playout
|
|
||||||
Map<CollectionKey, List<MediaItem>> collectionMediaItems = await GetCollectionMediaItems(blocksToSchedule); |
|
||||||
|
|
||||||
var itemBlockKeys = new Dictionary<PlayoutItem, BlockKey>(); |
|
||||||
foreach (PlayoutItem item in playout.Items) |
|
||||||
{ |
|
||||||
if (!string.IsNullOrWhiteSpace(item.BlockKey)) |
|
||||||
{ |
|
||||||
BlockKey blockKey = JsonConvert.DeserializeObject<BlockKey>(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<PlayoutHistory> 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<MediaItem> 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<HistoryDetails.Details>(history.Details); |
|
||||||
if (details.SeasonNumber.HasValue && details.EpisodeNumber.HasValue) |
|
||||||
{ |
|
||||||
Option<MediaItem> 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<MediaItem> 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<List<RealBlock>> GetBlocksToSchedule(Playout playout, DateTimeOffset start) |
|
||||||
{ |
|
||||||
int daysToBuild = await configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild) |
|
||||||
.IfNoneAsync(2); |
|
||||||
|
|
||||||
DateTimeOffset finish = start.AddDays(daysToBuild); |
|
||||||
|
|
||||||
var realBlocks = new List<RealBlock>(); |
|
||||||
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<string, List<PlayoutHistory>>(); |
|
||||||
foreach (PlayoutHistory history in playout.PlayoutHistory) |
|
||||||
{ |
|
||||||
var key = $"{history.BlockId}-{history.Key}"; |
|
||||||
if (!groups.TryGetValue(key, out List<PlayoutHistory> group)) |
|
||||||
{ |
|
||||||
group = []; |
|
||||||
groups[key] = group; |
|
||||||
} |
|
||||||
|
|
||||||
group.Add(history); |
|
||||||
} |
|
||||||
|
|
||||||
foreach ((string key, List<PlayoutHistory> group) in groups) |
|
||||||
{ |
|
||||||
//logger.LogDebug("History key {Key} has {Count} items in group", key, group.Count);
|
|
||||||
|
|
||||||
IEnumerable<PlayoutHistory> 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<Map<CollectionKey, List<MediaItem>>> GetCollectionMediaItems(List<RealBlock> realBlocks) |
|
||||||
{ |
|
||||||
var collectionKeys = realBlocks.Map(b => b.Block.Items) |
|
||||||
.Flatten() |
|
||||||
.DistinctBy(i => i.Id) |
|
||||||
.Map(CollectionKey.ForBlockItem) |
|
||||||
.Distinct() |
|
||||||
.ToList(); |
|
||||||
|
|
||||||
IEnumerable<Tuple<CollectionKey, List<MediaItem>>> 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); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -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; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Block Id
|
||||||
|
/// </summary>
|
||||||
|
public int b { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Template Id
|
||||||
|
/// </summary>
|
||||||
|
public int t { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playout Template Id
|
||||||
|
/// </summary>
|
||||||
|
public int pt { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Block Date Updated Ticks
|
||||||
|
/// </summary>
|
||||||
|
public long bt { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Template Date Updated Ticks
|
||||||
|
/// </summary>
|
||||||
|
public long tt { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playout Template Date Updated Ticks
|
||||||
|
/// </summary>
|
||||||
|
public long ptt { get; set; } |
||||||
|
} |
||||||
@ -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<BlockPlayoutBuilder> logger) |
||||||
|
: IBlockPlayoutBuilder |
||||||
|
{ |
||||||
|
public async Task<Playout> 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<int>(ConfigElementKey.PlayoutDaysToBuild) |
||||||
|
.IfNoneAsync(2); |
||||||
|
|
||||||
|
// get blocks to schedule
|
||||||
|
List<EffectiveBlock> blocksToSchedule = EffectiveBlock.GetEffectiveBlocks(playout, start, daysToBuild); |
||||||
|
|
||||||
|
// get all collection items for the playout
|
||||||
|
Map<CollectionKey, List<MediaItem>> collectionMediaItems = await GetCollectionMediaItems(blocksToSchedule); |
||||||
|
|
||||||
|
Dictionary<PlayoutItem, BlockKey> itemBlockKeys = BlockPlayoutChangeDetection.GetPlayoutItemToBlockKeyMap(playout); |
||||||
|
|
||||||
|
// remove items without a block key (shouldn't happen often, just upgrades)
|
||||||
|
playout.Items.RemoveAll(i => !itemBlockKeys.ContainsKey(i)); |
||||||
|
|
||||||
|
(List<EffectiveBlock> updatedEffectiveBlocks, List<PlayoutItem> 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<PlayoutHistory> 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<MediaItem> 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<HistoryDetails.Details>(history.Details); |
||||||
|
if (details.SeasonNumber.HasValue && details.EpisodeNumber.HasValue) |
||||||
|
{ |
||||||
|
Option<MediaItem> 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<MediaItem> 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<string, List<PlayoutHistory>>(); |
||||||
|
foreach (PlayoutHistory history in playout.PlayoutHistory) |
||||||
|
{ |
||||||
|
var key = $"{history.BlockId}-{history.Key}"; |
||||||
|
if (!groups.TryGetValue(key, out List<PlayoutHistory> group)) |
||||||
|
{ |
||||||
|
group = []; |
||||||
|
groups[key] = group; |
||||||
|
} |
||||||
|
|
||||||
|
group.Add(history); |
||||||
|
} |
||||||
|
|
||||||
|
foreach ((string key, List<PlayoutHistory> group) in groups) |
||||||
|
{ |
||||||
|
//logger.LogDebug("History key {Key} has {Count} items in group", key, group.Count);
|
||||||
|
|
||||||
|
IEnumerable<PlayoutHistory> 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<Map<CollectionKey, List<MediaItem>>> GetCollectionMediaItems( |
||||||
|
List<EffectiveBlock> effectiveBlocks) |
||||||
|
{ |
||||||
|
var collectionKeys = effectiveBlocks.Map(b => b.Block.Items) |
||||||
|
.Flatten() |
||||||
|
.DistinctBy(i => i.Id) |
||||||
|
.Map(CollectionKey.ForBlockItem) |
||||||
|
.Distinct() |
||||||
|
.ToList(); |
||||||
|
|
||||||
|
IEnumerable<Tuple<CollectionKey, List<MediaItem>>> 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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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<PlayoutItem, BlockKey> GetPlayoutItemToBlockKeyMap(Playout playout) |
||||||
|
{ |
||||||
|
var itemBlockKeys = new Dictionary<PlayoutItem, BlockKey>(); |
||||||
|
foreach (PlayoutItem item in playout.Items) |
||||||
|
{ |
||||||
|
if (!string.IsNullOrWhiteSpace(item.BlockKey)) |
||||||
|
{ |
||||||
|
BlockKey blockKey = JsonConvert.DeserializeObject<BlockKey>(item.BlockKey); |
||||||
|
itemBlockKeys.Add(item, blockKey); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return itemBlockKeys.ToDictionary(); |
||||||
|
} |
||||||
|
|
||||||
|
public static Tuple<List<EffectiveBlock>, List<PlayoutItem>> FindUpdatedItems( |
||||||
|
Playout playout, |
||||||
|
Dictionary<PlayoutItem, BlockKey> itemBlockKeys, |
||||||
|
List<EffectiveBlock> 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<EffectiveBlock>(); |
||||||
|
var updatedItems = new List<PlayoutItem>(); |
||||||
|
|
||||||
|
var earliestEffectiveBlocks = new Dictionary<BlockKey, DateTimeOffset>(); |
||||||
|
var earliestBlocks = new Dictionary<int, DateTimeOffset>(); |
||||||
|
|
||||||
|
// 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<PlayoutHistory> historyToRemove = playout.PlayoutHistory |
||||||
|
.Find(h => h.When == playoutItem.Start); |
||||||
|
|
||||||
|
foreach (PlayoutHistory history in historyToRemove) |
||||||
|
{ |
||||||
|
playout.PlayoutHistory.Remove(history); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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<EffectiveBlock> GetEffectiveBlocks(Playout playout, DateTimeOffset start, int daysToBuild) |
||||||
|
{ |
||||||
|
DateTimeOffset finish = start.AddDays(daysToBuild); |
||||||
|
|
||||||
|
var effectiveBlocks = new List<EffectiveBlock>(); |
||||||
|
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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
Loading…
Reference in new issue