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"
|
||||
@using ErsatzTV.Application.Playouts
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@using ErsatzTV.Core.Scheduling
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@@ -109,7 +110,6 @@
|
||||
}
|
||||
else if (context.PlayoutType == ProgramSchedulePlayoutType.Block)
|
||||
{
|
||||
<div style="width: 48px"></div>
|
||||
<MudTooltip Text="Edit Templates">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
@@ -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 @@
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -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 @@
|
||||
</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 @@
|
||||
public int Id { get; set; }
|
||||
|
||||
private TemplateItemsEditViewModel _template = new();
|
||||
private TemplateItemEditViewModel _blockToRemove = null;
|
||||
private BlockGroupViewModel _selectedBlockGroup;
|
||||
private BlockViewModel _selectedBlock;
|
||||
private DateTime _selectedBlockStart;
|
||||
@@ -186,7 +207,18 @@
|
||||
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)
|
||||
{
|
||||
// don't allow any overlap
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user