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 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Scheduling; |
||||
|
||||
public record EraseBlockPlayoutHistory(int PlayoutId) : IRequest; |
||||
@ -0,0 +1,29 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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