block schedule refactor (#1553)
* erase block playout history and items from playouts page * remove block from template * refactor block scheduling; improve history behavior
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
@page "/playouts"
|
@page "/playouts"
|
||||||
@using ErsatzTV.Application.Playouts
|
@using ErsatzTV.Application.Playouts
|
||||||
@using ErsatzTV.Application.Configuration
|
@using ErsatzTV.Application.Configuration
|
||||||
|
@using ErsatzTV.Application.Scheduling
|
||||||
@using ErsatzTV.Core.Scheduling
|
@using ErsatzTV.Core.Scheduling
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@inject IDialogService Dialog
|
@inject IDialogService Dialog
|
||||||
@@ -109,7 +110,6 @@
|
|||||||
}
|
}
|
||||||
else if (context.PlayoutType == ProgramSchedulePlayoutType.Block)
|
else if (context.PlayoutType == ProgramSchedulePlayoutType.Block)
|
||||||
{
|
{
|
||||||
<div style="width: 48px"></div>
|
|
||||||
<MudTooltip Text="Edit Templates">
|
<MudTooltip Text="Edit Templates">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||||
@@ -122,6 +122,12 @@
|
|||||||
OnClick="@(_ => ResetPlayout(context))">
|
OnClick="@(_ => ResetPlayout(context))">
|
||||||
</MudIconButton>
|
</MudIconButton>
|
||||||
</MudTooltip>
|
</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">
|
<MudTooltip Text="Delete Playout">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||||
@@ -274,6 +280,20 @@
|
|||||||
{
|
{
|
||||||
await _table.ReloadServerData();
|
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)
|
if (_selectedPlayoutId == playout.PlayoutId)
|
||||||
{
|
{
|
||||||
await PlayoutSelected(playout);
|
await PlayoutSelected(playout);
|
||||||
|
|||||||
@@ -31,9 +31,7 @@
|
|||||||
ValueChanged="@(vm => UpdateBlockGroupItems(vm))">
|
ValueChanged="@(vm => UpdateBlockGroupItems(vm))">
|
||||||
@foreach (BlockGroupViewModel blockGroup in _blockGroups)
|
@foreach (BlockGroupViewModel blockGroup in _blockGroups)
|
||||||
{
|
{
|
||||||
<MudSelectItem Value="@blockGroup">
|
<MudSelectItem Value="@blockGroup">@blockGroup.Name</MudSelectItem>
|
||||||
@blockGroup.Name
|
|
||||||
</MudSelectItem>
|
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
<MudSelect Class="mt-3"
|
<MudSelect Class="mt-3"
|
||||||
@@ -65,6 +63,28 @@
|
|||||||
</MudCard>
|
</MudCard>
|
||||||
</div>
|
</div>
|
||||||
</MudItem>
|
</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">
|
<MudItem xs="8">
|
||||||
<MudCalendar Class="mt-4"
|
<MudCalendar Class="mt-4"
|
||||||
Items="@_template.Items"
|
Items="@_template.Items"
|
||||||
@@ -92,6 +112,7 @@
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
||||||
private TemplateItemsEditViewModel _template = new();
|
private TemplateItemsEditViewModel _template = new();
|
||||||
|
private TemplateItemEditViewModel _blockToRemove = null;
|
||||||
private BlockGroupViewModel _selectedBlockGroup;
|
private BlockGroupViewModel _selectedBlockGroup;
|
||||||
private BlockViewModel _selectedBlock;
|
private BlockViewModel _selectedBlock;
|
||||||
private DateTime _selectedBlockStart;
|
private DateTime _selectedBlockStart;
|
||||||
@@ -186,7 +207,18 @@
|
|||||||
maybeStart = maybeStart.AddMinutes(15);
|
maybeStart = maybeStart.AddMinutes(15);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task RemoveBlockFromTemplate()
|
||||||
|
{
|
||||||
|
if (_blockToRemove is not null)
|
||||||
|
{
|
||||||
|
_template.Items.Remove(_blockToRemove);
|
||||||
|
_blockToRemove = null;
|
||||||
|
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void CalendarItemChanged(CalendarItem calendarItem)
|
private void CalendarItemChanged(CalendarItem calendarItem)
|
||||||
{
|
{
|
||||||
// don't allow any overlap
|
// don't allow any overlap
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
<BodyContent>
|
<BodyContent>
|
||||||
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
|
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
|
||||||
<MudGrid Justify="Justify.FlexStart">
|
<MudGrid Justify="Justify.FlexStart">
|
||||||
<MudItem xs="3">
|
<MudItem xs="8">
|
||||||
<MudText>@item.Text</MudText>
|
<MudText>@item.Text</MudText>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ using ErsatzTV.Core.Jellyfin;
|
|||||||
using ErsatzTV.Core.Metadata;
|
using ErsatzTV.Core.Metadata;
|
||||||
using ErsatzTV.Core.Plex;
|
using ErsatzTV.Core.Plex;
|
||||||
using ErsatzTV.Core.Scheduling;
|
using ErsatzTV.Core.Scheduling;
|
||||||
|
using ErsatzTV.Core.Scheduling.BlockScheduling;
|
||||||
using ErsatzTV.Core.Trakt;
|
using ErsatzTV.Core.Trakt;
|
||||||
using ErsatzTV.FFmpeg.Capabilities;
|
using ErsatzTV.FFmpeg.Capabilities;
|
||||||
using ErsatzTV.FFmpeg.Pipeline;
|
using ErsatzTV.FFmpeg.Pipeline;
|
||||||
|
|||||||
Reference in New Issue
Block a user