Browse Source

block schedule refactor (#1553)

* erase block playout history and items from playouts page

* remove block from template

* refactor block scheduling; improve history behavior
pull/1554/head
Jason Dove 2 years ago committed by GitHub
parent
commit
adbd0bcec0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      ErsatzTV.Application/Scheduling/Commands/EraseBlockPlayoutHistory.cs
  2. 29
      ErsatzTV.Application/Scheduling/Commands/EraseBlockPlayoutHistoryHandler.cs
  3. 478
      ErsatzTV.Core/Scheduling/BlockPlayoutBuilder.cs
  4. 52
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockKey.cs
  5. 270
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs
  6. 114
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutChangeDetection.cs
  7. 51
      ErsatzTV.Core/Scheduling/BlockScheduling/EffectiveBlock.cs
  8. 55
      ErsatzTV.Core/Scheduling/BlockScheduling/HistoryDetails.cs
  9. 22
      ErsatzTV/Pages/Playouts.razor
  10. 38
      ErsatzTV/Pages/TemplateEditor.razor
  11. 2
      ErsatzTV/Pages/Templates.razor
  12. 1
      ErsatzTV/Startup.cs

3
ErsatzTV.Application/Scheduling/Commands/EraseBlockPlayoutHistory.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Scheduling;
public record EraseBlockPlayoutHistory(int PlayoutId) : IRequest;

29
ErsatzTV.Application/Scheduling/Commands/EraseBlockPlayoutHistoryHandler.cs

@ -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);
}
}
}

478
ErsatzTV.Core/Scheduling/BlockPlayoutBuilder.cs

@ -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);
}
}

52
ErsatzTV.Core/Scheduling/BlockScheduling/BlockKey.cs

@ -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; }
}

270
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs

@ -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;
}
}

114
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutChangeDetection.cs

@ -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);
}
}
}

51
ErsatzTV.Core/Scheduling/BlockScheduling/EffectiveBlock.cs

@ -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;
}
}

55
ErsatzTV.Core/Scheduling/BlockScheduling/HistoryDetails.cs

@ -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);
}

22
ErsatzTV/Pages/Playouts.razor

@ -1,6 +1,7 @@ @@ -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 @@ @@ -109,7 +110,6 @@
}
else if (context.PlayoutType == ProgramSchedulePlayoutType.Block)
{
<div style="width: 48px"></div>
<MudTooltip Text="Edit Templates">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
@ -122,6 +122,12 @@ @@ -122,6 +122,12 @@
OnClick="@(_ => ResetPlayout(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Erase Items and History">
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => EraseHistory(context))">
</MudIconButton>
</MudTooltip>
}
<MudTooltip Text="Delete Playout">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
@ -274,6 +280,20 @@ @@ -274,6 +280,20 @@
{
await _table.ReloadServerData();
}
if (_selectedPlayoutId == playout.PlayoutId)
{
await PlayoutSelected(playout);
}
}
private async Task EraseHistory(PlayoutNameViewModel playout)
{
if (playout.PlayoutType is ProgramSchedulePlayoutType.Block)
{
await Mediator.Send(new EraseBlockPlayoutHistory(playout.PlayoutId), _cts.Token);
}
if (_selectedPlayoutId == playout.PlayoutId)
{
await PlayoutSelected(playout);

38
ErsatzTV/Pages/TemplateEditor.razor

@ -31,9 +31,7 @@ @@ -31,9 +31,7 @@
ValueChanged="@(vm => UpdateBlockGroupItems(vm))">
@foreach (BlockGroupViewModel blockGroup in _blockGroups)
{
<MudSelectItem Value="@blockGroup">
@blockGroup.Name
</MudSelectItem>
<MudSelectItem Value="@blockGroup">@blockGroup.Name</MudSelectItem>
}
</MudSelect>
<MudSelect Class="mt-3"
@ -65,6 +63,28 @@ @@ -65,6 +63,28 @@
</MudCard>
</div>
</MudItem>
<MudItem xs="4">
<div style="max-width: 400px">
<MudCard>
<MudCardContent>
<MudSelect T="TemplateItemEditViewModel"
Label="Block To Remove"
@bind-Value="_blockToRemove">
<MudSelectItem Value="@((TemplateItemEditViewModel)null)">(none)</MudSelectItem>
@foreach (TemplateItemEditViewModel item in _template.Items.OrderBy(i => i.Start))
{
<MudSelectItem Value="@item">@item.Start.ToShortTimeString() - @item.Text</MudSelectItem>
}
</MudSelect>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => RemoveBlockFromTemplate())" Disabled="@(_blockToRemove is null)">
Remove Block From Template
</MudButton>
</MudCardActions>
</MudCard>
</div>
</MudItem>
<MudItem xs="8">
<MudCalendar Class="mt-4"
Items="@_template.Items"
@ -92,6 +112,7 @@ @@ -92,6 +112,7 @@
public int Id { get; set; }
private TemplateItemsEditViewModel _template = new();
private TemplateItemEditViewModel _blockToRemove = null;
private BlockGroupViewModel _selectedBlockGroup;
private BlockViewModel _selectedBlock;
private DateTime _selectedBlockStart;
@ -187,6 +208,17 @@ @@ -187,6 +208,17 @@
}
}
private async Task RemoveBlockFromTemplate()
{
if (_blockToRemove is not null)
{
_template.Items.Remove(_blockToRemove);
_blockToRemove = null;
await InvokeAsync(StateHasChanged);
}
}
private void CalendarItemChanged(CalendarItem calendarItem)
{
// don't allow any overlap

2
ErsatzTV/Pages/Templates.razor

@ -54,7 +54,7 @@ @@ -54,7 +54,7 @@
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudGrid Justify="Justify.FlexStart">
<MudItem xs="3">
<MudItem xs="8">
<MudText>@item.Text</MudText>
</MudItem>
</MudGrid>

1
ErsatzTV/Startup.cs

@ -33,6 +33,7 @@ using ErsatzTV.Core.Jellyfin; @@ -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;

Loading…
Cancel
Save