Browse Source

block scheduling skip unchanged blocks (#1550)

* schedule blocks in order

* block minutes must be multiple of 15

* improve block minutes entry, validation and display

* confirm deleting blocks and block groups

* confirm deleting templates and template groups

* skip unchanged blocks in playout
pull/1551/head
Jason Dove 2 years ago committed by GitHub
parent
commit
caef4a139e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      ErsatzTV.Application/Scheduling/Commands/ReplaceBlockItemsHandler.cs
  2. 6
      ErsatzTV.Application/Scheduling/Commands/ReplacePlayoutTemplateItemsHandler.cs
  3. 1
      ErsatzTV.Application/Scheduling/Commands/ReplaceTemplateItemsHandler.cs
  4. 1
      ErsatzTV.Core/Domain/PlayoutItem.cs
  5. 1
      ErsatzTV.Core/Domain/Scheduling/Block.cs
  6. 1
      ErsatzTV.Core/Domain/Scheduling/PlayoutTemplate.cs
  7. 1
      ErsatzTV.Core/Domain/Scheduling/Template.cs
  8. 171
      ErsatzTV.Core/Scheduling/BlockPlayoutBuilder.cs
  9. 4895
      ErsatzTV.Infrastructure.MySql/Migrations/20240114104626_Fix_BlockMinutes.Designer.cs
  10. 21
      ErsatzTV.Infrastructure.MySql/Migrations/20240114104626_Fix_BlockMinutes.cs
  11. 4907
      ErsatzTV.Infrastructure.MySql/Migrations/20240114120807_Add_PlayoutItemBlockKey.Designer.cs
  12. 63
      ErsatzTV.Infrastructure.MySql/Migrations/20240114120807_Add_PlayoutItemBlockKey.cs
  13. 4907
      ErsatzTV.Infrastructure.MySql/Migrations/20240114120924_Reset_BlockPlayouts_BlockKey.Designer.cs
  14. 26
      ErsatzTV.Infrastructure.MySql/Migrations/20240114120924_Reset_BlockPlayouts_BlockKey.cs
  15. 12
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  16. 4893
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240114103704_Fix_BlockMinutes.Designer.cs
  17. 21
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240114103704_Fix_BlockMinutes.cs
  18. 4905
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240114113426_Add_PlayoutItemBlockKey.Designer.cs
  19. 62
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240114113426_Add_PlayoutItemBlockKey.cs
  20. 4905
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240114120205_Reset_BlockPlayouts_BlockKey.Designer.cs
  21. 26
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240114120205_Reset_BlockPlayouts_BlockKey.cs
  22. 38
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  23. 31
      ErsatzTV/Pages/BlockEditor.razor
  24. 19
      ErsatzTV/Pages/Blocks.razor
  25. 17
      ErsatzTV/Pages/Templates.razor
  26. 13
      ErsatzTV/ViewModels/BlockTreeItemViewModel.cs

9
ErsatzTV.Application/Scheduling/Commands/ReplaceBlockItemsHandler.cs

@ -26,6 +26,7 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact @@ -26,6 +26,7 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
{
block.Name = request.Name;
block.Minutes = request.Minutes;
block.DateUpdated = DateTime.UtcNow;
dbContext.RemoveRange(block.Items);
block.Items = request.Items.Map(i => BuildItem(block, i.Index, i)).ToList();
@ -56,7 +57,8 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact @@ -56,7 +57,8 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
private static Task<Validation<BaseError, Block>> Validate(TvContext dbContext, ReplaceBlockItems request) =>
BlockMustExist(dbContext, request.BlockId)
.BindT(programSchedule => CollectionTypesMustBeValid(request, programSchedule));
.BindT(block => MinutesMustBeValid(request, block))
.BindT(block => CollectionTypesMustBeValid(request, block));
private static Task<Validation<BaseError, Block>> BlockMustExist(TvContext dbContext, int blockId) =>
dbContext.Blocks
@ -64,6 +66,11 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact @@ -64,6 +66,11 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
.SelectOneAsync(b => b.Id, b => b.Id == blockId)
.Map(o => o.ToValidation<BaseError>("[BlockId] does not exist."));
private static Validation<BaseError, Block> MinutesMustBeValid(ReplaceBlockItems request, Block block) =>
Optional(block)
.Filter(_ => request.Minutes > 0 && request.Minutes % 15 == 0)
.ToValidation<BaseError>("Block Minutes must be a positive multiple of 15");
private static Validation<BaseError, Block> CollectionTypesMustBeValid(ReplaceBlockItems request, Block block) =>
request.Items.Map(item => CollectionTypeMustBeValid(item, block)).Sequence().Map(_ => block);

6
ErsatzTV.Application/Scheduling/Commands/ReplacePlayoutTemplateItemsHandler.cs

@ -42,6 +42,8 @@ public class ReplacePlayoutTemplateItemsHandler( @@ -42,6 +42,8 @@ public class ReplacePlayoutTemplateItemsHandler(
playout.Templates.Remove(remove);
}
var now = DateTime.UtcNow;
foreach (ReplacePlayoutTemplate add in toAdd)
{
playout.Templates.Add(
@ -52,7 +54,8 @@ public class ReplacePlayoutTemplateItemsHandler( @@ -52,7 +54,8 @@ public class ReplacePlayoutTemplateItemsHandler(
TemplateId = add.TemplateId,
DaysOfWeek = add.DaysOfWeek,
DaysOfMonth = add.DaysOfMonth,
MonthsOfYear = add.MonthsOfYear
MonthsOfYear = add.MonthsOfYear,
DateUpdated = now
});
}
@ -65,6 +68,7 @@ public class ReplacePlayoutTemplateItemsHandler( @@ -65,6 +68,7 @@ public class ReplacePlayoutTemplateItemsHandler(
ex.DaysOfWeek = update.DaysOfWeek;
ex.DaysOfMonth = update.DaysOfMonth;
ex.MonthsOfYear = update.MonthsOfYear;
ex.DateUpdated = now;
}
}

1
ErsatzTV.Application/Scheduling/Commands/ReplaceTemplateItemsHandler.cs

@ -24,6 +24,7 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF @@ -24,6 +24,7 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
Template template)
{
template.Name = request.Name;
template.DateUpdated = DateTime.UtcNow;
dbContext.RemoveRange(template.Items);
template.Items = request.Items.Map(i => BuildItem(template, i)).ToList();

1
ErsatzTV.Core/Domain/PlayoutItem.cs

@ -27,6 +27,7 @@ public class PlayoutItem @@ -27,6 +27,7 @@ public class PlayoutItem
public string PreferredAudioTitle { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode? SubtitleMode { get; set; }
public string BlockKey { get; set; }
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();

1
ErsatzTV.Core/Domain/Scheduling/Block.cs

@ -10,4 +10,5 @@ public class Block @@ -10,4 +10,5 @@ public class Block
public ICollection<BlockItem> Items { get; set; }
public ICollection<TemplateItem> TemplateItems { get; set; }
public ICollection<PlayoutHistory> PlayoutHistory { get; set; }
public DateTime DateUpdated { get; set; }
}

1
ErsatzTV.Core/Domain/Scheduling/PlayoutTemplate.cs

@ -13,6 +13,7 @@ public class PlayoutTemplate @@ -13,6 +13,7 @@ public class PlayoutTemplate
public ICollection<int> MonthsOfYear { get; set; }
public DateTimeOffset StartDate { get; set; }
public DateTimeOffset EndDate { get; set; }
public DateTime DateUpdated { get; set; }
// TODO: ICollection<DateTimeOffset> AdditionalDays { get; set; }

1
ErsatzTV.Core/Domain/Scheduling/Template.cs

@ -8,4 +8,5 @@ public class Template @@ -8,4 +8,5 @@ public class Template
public string Name { get; set; }
public ICollection<TemplateItem> Items { get; set; }
public ICollection<PlayoutTemplate> PlayoutTemplates { get; set; }
public DateTime DateUpdated { get; set; }
}

171
ErsatzTV.Core/Scheduling/BlockPlayoutBuilder.cs

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
@ -17,6 +18,11 @@ public class BlockPlayoutBuilder( @@ -17,6 +18,11 @@ public class BlockPlayoutBuilder(
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(
@ -26,6 +32,9 @@ public class BlockPlayoutBuilder( @@ -26,6 +32,9 @@ public class BlockPlayoutBuilder(
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);
@ -33,20 +42,49 @@ public class BlockPlayoutBuilder( @@ -33,20 +42,49 @@ public class BlockPlayoutBuilder(
// get all collection items for the playout
Map<CollectionKey, List<MediaItem>> collectionMediaItems = await GetCollectionMediaItems(blocksToSchedule);
// TODO: REMOVE THIS !!!
playout.Items.Clear();
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);
}
}
// TODO: REMOVE THIS !!!
var historyToRemove = playout.PlayoutHistory
.Filter(h => h.When > start.UtcDateTime)
.ToList();
foreach (PlayoutHistory remove in historyToRemove)
// 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)
{
playout.PlayoutHistory.Remove(remove);
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,
@ -62,14 +100,9 @@ public class BlockPlayoutBuilder( @@ -62,14 +100,9 @@ public class BlockPlayoutBuilder(
continue;
}
// TODO: check if change is needed - if not, skip building
// - block can change
// - template can change
// - playout templates can change
// TODO: check for playout history for this collection
// 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);
//logger.LogDebug("History key for block item {Item} is {Key}", blockItem.Id, historyKey);
DateTime historyTime = currentTime.UtcDateTime;
Option<PlayoutHistory> maybeHistory = playout.PlayoutHistory
@ -145,7 +178,7 @@ public class BlockPlayoutBuilder( @@ -145,7 +178,7 @@ public class BlockPlayoutBuilder(
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
// TODO: create a playout item
// create a playout item
var playoutItem = new PlayoutItem
{
MediaItemId = mediaItem.Id,
@ -160,11 +193,12 @@ public class BlockPlayoutBuilder( @@ -160,11 +193,12 @@ public class BlockPlayoutBuilder(
//PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
//PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
//SubtitleMode = scheduleItem.SubtitleMode
BlockKey = JsonConvert.SerializeObject(realBlock.BlockKey)
};
playout.Items.Add(playoutItem);
// TODO: create a playout history record
// create a playout history record
var nextHistory = new PlayoutHistory
{
PlayoutId = playout.Id,
@ -174,6 +208,7 @@ public class BlockPlayoutBuilder( @@ -174,6 +208,7 @@ public class BlockPlayoutBuilder(
Details = HistoryDetails.ForMediaItem(mediaItem)
};
//logger.LogDebug("Adding history item: {When}: {History}", nextHistory.When, nextHistory.Details);
playout.PlayoutHistory.Add(nextHistory);
currentTime += itemDuration;
@ -204,6 +239,64 @@ public class BlockPlayoutBuilder( @@ -204,6 +239,64 @@ public class BlockPlayoutBuilder(
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)
@ -226,6 +319,7 @@ public class BlockPlayoutBuilder( @@ -226,6 +319,7 @@ public class BlockPlayoutBuilder(
{
var realBlock = new RealBlock(
templateItem.Block,
new BlockKey(templateItem.Block, templateItem.Template, playoutTemplate),
new DateTimeOffset(
current.Year,
current.Month,
@ -243,6 +337,7 @@ public class BlockPlayoutBuilder( @@ -243,6 +337,7 @@ public class BlockPlayoutBuilder(
}
realBlocks.RemoveAll(b => b.Start.AddMinutes(b.Block.Minutes) < start || b.Start > finish);
realBlocks = realBlocks.OrderBy(rb => rb.Start).ToList();
return realBlocks;
}
@ -264,7 +359,7 @@ public class BlockPlayoutBuilder( @@ -264,7 +359,7 @@ public class BlockPlayoutBuilder(
foreach ((string key, List<PlayoutHistory> group) in groups)
{
logger.LogDebug("History key {Key} has {Count} items in group", key, group.Count);
//logger.LogDebug("History key {Key} has {Count} items in group", key, group.Count);
IEnumerable<PlayoutHistory> toDelete = group
.Filter(h => h.When < start.UtcDateTime)
@ -305,15 +400,36 @@ public class BlockPlayoutBuilder( @@ -305,15 +400,36 @@ public class BlockPlayoutBuilder(
return version.Duration;
}
private record RealBlock(Block Block, DateTimeOffset Start);
private record RealBlock(Block Block, BlockKey BlockKey, DateTimeOffset Start);
private static class HistoryKey
[SuppressMessage("ReSharper", "InconsistentNaming")]
private record BlockKey
{
private static readonly JsonSerializerSettings Settings = new()
public BlockKey()
{
NullValueHandling = NullValueHandling.Ignore
};
}
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
@ -327,17 +443,12 @@ public class BlockPlayoutBuilder( @@ -327,17 +443,12 @@ public class BlockPlayoutBuilder(
blockItem.MediaItemId
};
return JsonConvert.SerializeObject(key, Formatting.None, Settings);
return JsonConvert.SerializeObject(key, Formatting.None, JsonSettings);
}
}
private static class HistoryDetails
{
private static readonly JsonSerializerSettings Settings = new()
{
NullValueHandling = NullValueHandling.Ignore
};
public static string ForMediaItem(MediaItem mediaItem)
{
Details details = mediaItem switch
@ -346,7 +457,7 @@ public class BlockPlayoutBuilder( @@ -346,7 +457,7 @@ public class BlockPlayoutBuilder(
_ => new Details(mediaItem.Id, null, null, null)
};
return JsonConvert.SerializeObject(details, Formatting.None, Settings);
return JsonConvert.SerializeObject(details, Formatting.None, JsonSettings);
}
private static Details ForEpisode(Episode e)

4895
ErsatzTV.Infrastructure.MySql/Migrations/20240114104626_Fix_BlockMinutes.Designer.cs generated

File diff suppressed because it is too large Load Diff

21
ErsatzTV.Infrastructure.MySql/Migrations/20240114104626_Fix_BlockMinutes.cs

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Fix_BlockMinutes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE Block SET Minutes = CAST(CEILING(Minutes / 15.0) * 15 AS INT)");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

4907
ErsatzTV.Infrastructure.MySql/Migrations/20240114120807_Add_PlayoutItemBlockKey.Designer.cs generated

File diff suppressed because it is too large Load Diff

63
ErsatzTV.Infrastructure.MySql/Migrations/20240114120807_Add_PlayoutItemBlockKey.cs

@ -0,0 +1,63 @@ @@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutItemBlockKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "DateUpdated",
table: "Template",
type: "datetime(6)",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<DateTime>(
name: "DateUpdated",
table: "PlayoutTemplate",
type: "datetime(6)",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<string>(
name: "BlockKey",
table: "PlayoutItem",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<DateTime>(
name: "DateUpdated",
table: "Block",
type: "datetime(6)",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DateUpdated",
table: "Template");
migrationBuilder.DropColumn(
name: "DateUpdated",
table: "PlayoutTemplate");
migrationBuilder.DropColumn(
name: "BlockKey",
table: "PlayoutItem");
migrationBuilder.DropColumn(
name: "DateUpdated",
table: "Block");
}
}
}

4907
ErsatzTV.Infrastructure.MySql/Migrations/20240114120924_Reset_BlockPlayouts_BlockKey.Designer.cs generated

File diff suppressed because it is too large Load Diff

26
ErsatzTV.Infrastructure.MySql/Migrations/20240114120924_Reset_BlockPlayouts_BlockKey.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Reset_BlockPlayouts_BlockKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"DELETE FROM PlayoutHistory");
migrationBuilder.Sql(
"""
DELETE FROM PlayoutItem
WHERE PlayoutId IN (SELECT Id FROM Playout WHERE ProgramSchedulePlayoutType = 2)
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

12
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -1437,6 +1437,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1437,6 +1437,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("BlockKey")
.HasColumnType("longtext");
b.Property<string>("ChapterTitle")
.HasColumnType("longtext");
@ -1829,6 +1832,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1829,6 +1832,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("BlockGroupId")
.HasColumnType("int");
b.Property<DateTime>("DateUpdated")
.HasColumnType("datetime(6)");
b.Property<int>("Minutes")
.HasColumnType("int");
@ -1943,6 +1949,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1943,6 +1949,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("DateUpdated")
.HasColumnType("datetime(6)");
b.Property<string>("DaysOfMonth")
.HasColumnType("longtext");
@ -1982,6 +1991,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1982,6 +1991,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("DateUpdated")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.HasColumnType("varchar(255)");

4893
ErsatzTV.Infrastructure.Sqlite/Migrations/20240114103704_Fix_BlockMinutes.Designer.cs generated

File diff suppressed because it is too large Load Diff

21
ErsatzTV.Infrastructure.Sqlite/Migrations/20240114103704_Fix_BlockMinutes.cs

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Fix_BlockMinutes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE Block SET Minutes = CAST(CEILING(Minutes / 15.0) * 15 AS INT)");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

4905
ErsatzTV.Infrastructure.Sqlite/Migrations/20240114113426_Add_PlayoutItemBlockKey.Designer.cs generated

File diff suppressed because it is too large Load Diff

62
ErsatzTV.Infrastructure.Sqlite/Migrations/20240114113426_Add_PlayoutItemBlockKey.cs

@ -0,0 +1,62 @@ @@ -0,0 +1,62 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutItemBlockKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "DateUpdated",
table: "Template",
type: "TEXT",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<DateTime>(
name: "DateUpdated",
table: "PlayoutTemplate",
type: "TEXT",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<string>(
name: "BlockKey",
table: "PlayoutItem",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "DateUpdated",
table: "Block",
type: "TEXT",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DateUpdated",
table: "Template");
migrationBuilder.DropColumn(
name: "DateUpdated",
table: "PlayoutTemplate");
migrationBuilder.DropColumn(
name: "BlockKey",
table: "PlayoutItem");
migrationBuilder.DropColumn(
name: "DateUpdated",
table: "Block");
}
}
}

4905
ErsatzTV.Infrastructure.Sqlite/Migrations/20240114120205_Reset_BlockPlayouts_BlockKey.Designer.cs generated

File diff suppressed because it is too large Load Diff

26
ErsatzTV.Infrastructure.Sqlite/Migrations/20240114120205_Reset_BlockPlayouts_BlockKey.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Reset_BlockPlayouts_BlockKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"DELETE FROM PlayoutHistory");
migrationBuilder.Sql(
"""
DELETE FROM PlayoutItem
WHERE PlayoutId IN (SELECT Id FROM Playout WHERE ProgramSchedulePlayoutType = 2)
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

38
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -715,7 +715,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -715,7 +715,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("SongMetadataId");
b.ToTable("Genre", (string)null);
b.ToTable("Genre");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinCollection", b =>
@ -1168,7 +1168,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1168,7 +1168,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("ArtistMetadataId");
b.ToTable("Mood", (string)null);
b.ToTable("Mood");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b =>
@ -1276,7 +1276,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1276,7 +1276,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("SmartCollectionId");
b.ToTable("MultiCollectionSmartItem", (string)null);
b.ToTable("MultiCollectionSmartItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoArtist", b =>
@ -1295,7 +1295,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1295,7 +1295,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("MusicVideoMetadataId");
b.ToTable("MusicVideoArtist", (string)null);
b.ToTable("MusicVideoArtist");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b =>
@ -1435,6 +1435,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1435,6 +1435,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlockKey")
.HasColumnType("TEXT");
b.Property<string>("ChapterTitle")
.HasColumnType("TEXT");
@ -1827,6 +1830,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1827,6 +1830,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("BlockGroupId")
.HasColumnType("INTEGER");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<int>("Minutes")
.HasColumnType("INTEGER");
@ -1923,7 +1929,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1923,7 +1929,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("When")
b.Property<DateTime>("When")
.HasColumnType("TEXT");
b.HasKey("Id");
@ -1941,6 +1947,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1941,6 +1947,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<string>("DaysOfMonth")
.HasColumnType("TEXT");
@ -1980,6 +1989,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1980,6 +1989,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
@ -2274,7 +2286,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2274,7 +2286,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("ArtistMetadataId");
b.ToTable("Style", (string)null);
b.ToTable("Style");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Subtitle", b =>
@ -2422,7 +2434,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2422,7 +2434,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("SongMetadataId");
b.ToTable("Tag", (string)null);
b.ToTable("Tag");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktList", b =>
@ -2563,7 +2575,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2563,7 +2575,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("EmbyLibraryId");
b.ToTable("EmbyPathInfo", (string)null);
b.ToTable("EmbyPathInfo");
});
modelBuilder.Entity("ErsatzTV.Core.Jellyfin.JellyfinPathInfo", b =>
@ -2585,7 +2597,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2585,7 +2597,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("JellyfinLibraryId");
b.ToTable("JellyfinPathInfo", (string)null);
b.ToTable("JellyfinPathInfo");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyLibrary", b =>
@ -3545,7 +3557,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3545,7 +3557,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("ErsatzTV.Core.Domain.Playout.Anchor#ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 =>
b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 =>
{
b1.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
@ -3575,7 +3587,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3575,7 +3587,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b1.WithOwner()
.HasForeignKey("PlayoutId");
b1.OwnsOne("ErsatzTV.Core.Domain.Playout.Anchor#ErsatzTV.Core.Domain.PlayoutAnchor.ScheduleItemsEnumeratorState#ErsatzTV.Core.Domain.CollectionEnumeratorState", "ScheduleItemsEnumeratorState", b2 =>
b1.OwnsOne("ErsatzTV.Core.Domain.CollectionEnumeratorState", "ScheduleItemsEnumeratorState", b2 =>
{
b2.Property<int>("PlayoutAnchorPlayoutId")
.HasColumnType("INTEGER");
@ -3658,7 +3670,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3658,7 +3670,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("SmartCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor.EnumeratorState#ErsatzTV.Core.Domain.CollectionEnumeratorState", "EnumeratorState", b1 =>
b.OwnsOne("ErsatzTV.Core.Domain.CollectionEnumeratorState", "EnumeratorState", b1 =>
{
b1.Property<int>("PlayoutProgramScheduleAnchorId")
.HasColumnType("INTEGER");
@ -3704,7 +3716,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3704,7 +3716,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.PlayoutScheduleItemFillGroupIndex.EnumeratorState#ErsatzTV.Core.Domain.CollectionEnumeratorState", "EnumeratorState", b1 =>
b.OwnsOne("ErsatzTV.Core.Domain.CollectionEnumeratorState", "EnumeratorState", b1 =>
{
b1.Property<int>("PlayoutScheduleItemFillGroupIndexId")
.HasColumnType("INTEGER");

31
ErsatzTV/Pages/BlockEditor.razor

@ -15,10 +15,23 @@ @@ -15,10 +15,23 @@
<MudCard>
<MudCardContent>
<MudTextField Label="Name" @bind-Value="_block.Name" For="@(() => _block.Name)"/>
<MudTextField Label="Duration" @bind-Value="_block.Minutes"
For="@(() => _block.Minutes)"
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
<MudItem xs="6">
<MudTextField T="int"
Label="Duration"
@bind-Value="_durationHours"
Adornment="Adornment.End"
AdornmentText="minutes" />
AdornmentText="hours"/>
</MudItem>
<MudItem xs="6">
<MudSelect T="int" @bind-Value="_durationMinutes" Adornment="Adornment.End" AdornmentText="minutes">
<MudSelectItem Value="0" />
<MudSelectItem Value="15" />
<MudSelectItem Value="30" />
<MudSelectItem Value="45" />
</MudSelect>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
</div>
@ -202,8 +215,10 @@ @@ -202,8 +215,10 @@
[Parameter]
public int Id { get; set; }
private BlockItemsEditViewModel _block = new();
private BlockItemsEditViewModel _block = new() { Items = [] };
private BlockItemEditViewModel _selectedItem;
private int _durationHours = 0;
private int _durationMinutes = 15;
public void Dispose()
{
@ -230,6 +245,9 @@ @@ -230,6 +245,9 @@
Minutes = block.Minutes,
Items = []
};
_durationHours = _block.Minutes / 60;
_durationMinutes = _block.Minutes % 60;
}
Option<IEnumerable<BlockItemViewModel>> maybeResults = await Mediator.Send(new GetBlockItems(Id), _cts.Token);
@ -361,7 +379,10 @@ @@ -361,7 +379,10 @@
item.MediaItem?.MediaItemId,
item.PlaybackOrder)).ToList();
Seq<BaseError> errorMessages = await Mediator.Send(new ReplaceBlockItems(Id, _block.Name, _block.Minutes, items), _cts.Token)
_block.Minutes = _durationHours * 60 + _durationMinutes;
Seq<BaseError> errorMessages = await Mediator
.Send(new ReplaceBlockItems(Id, _block.Name, _block.Minutes, items), _cts.Token)
.Map(e => e.LeftToSeq());
errorMessages.HeadOrNone().Match(

19
ErsatzTV/Pages/Blocks.razor

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
@inject ILogger<Blocks> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h4" Class="mb-4">Blocks</MudText>
@ -53,7 +54,7 @@ @@ -53,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="5">
<MudText>@item.Text</MudText>
</MudItem>
@if (!string.IsNullOrWhiteSpace(item.EndText))
@ -172,19 +173,30 @@ @@ -172,19 +173,30 @@
{
foreach (int blockGroupId in Optional(treeItem.BlockGroupId))
{
// TODO: confirmation
var parameters = new DialogParameters { { "EntityType", "block group" }, { "EntityName", treeItem.Text } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Block Group", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Canceled)
{
await Mediator.Send(new DeleteBlockGroup(blockGroupId), _cts.Token);
TreeItems.RemoveWhere(i => i.BlockGroupId == blockGroupId);
_blockGroups = await Mediator.Send(new GetAllBlockGroups(), _cts.Token);
await InvokeAsync(StateHasChanged);
}
}
foreach (int blockId in Optional(treeItem.BlockId))
{
// TODO: confirmation
var parameters = new DialogParameters { { "EntityType", "block" }, { "EntityName", treeItem.Text } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Block", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Canceled)
{
await Mediator.Send(new DeleteBlock(blockId), _cts.Token);
foreach (BlockTreeItemViewModel parent in TreeItems)
{
@ -194,4 +206,5 @@ @@ -194,4 +206,5 @@
await InvokeAsync(StateHasChanged);
}
}
}
}

17
ErsatzTV/Pages/Templates.razor

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
@inject ILogger<Templates> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h4" Class="mb-4">Templates</MudText>
@ -166,19 +167,30 @@ @@ -166,19 +167,30 @@
{
foreach (int templateGroupId in Optional(treeItem.TemplateGroupId))
{
// TODO: confirmation
var parameters = new DialogParameters { { "EntityType", "template group" }, { "EntityName", treeItem.Text } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Template Group", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Canceled)
{
await Mediator.Send(new DeleteTemplateGroup(templateGroupId), _cts.Token);
TreeItems.RemoveWhere(i => i.TemplateGroupId == templateGroupId);
_templateGroups = await Mediator.Send(new GetAllTemplateGroups(), _cts.Token);
await InvokeAsync(StateHasChanged);
}
}
foreach (int templateId in Optional(treeItem.TemplateId))
{
// TODO: confirmation
var parameters = new DialogParameters { { "EntityType", "template" }, { "EntityName", treeItem.Text } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Template", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Canceled)
{
await Mediator.Send(new DeleteTemplate(templateId), _cts.Token);
foreach (TemplateTreeItemViewModel parent in TreeItems)
{
@ -188,4 +200,5 @@ @@ -188,4 +200,5 @@
await InvokeAsync(StateHasChanged);
}
}
}
}

13
ErsatzTV/ViewModels/BlockTreeItemViewModel.cs

@ -19,7 +19,20 @@ public class BlockTreeItemViewModel @@ -19,7 +19,20 @@ public class BlockTreeItemViewModel
public BlockTreeItemViewModel(BlockViewModel block)
{
Text = block.Name;
if (block.Minutes / 60 >= 1)
{
string plural = block.Minutes / 60 >= 2 ? "s" : string.Empty;
EndText = $"{block.Minutes / 60} hour{plural}";
if (block.Minutes % 60 != 0)
{
EndText += $", {block.Minutes % 60} minutes";
}
}
else
{
EndText = $"{block.Minutes} minutes";
}
TreeItems = [];
CanExpand = false;
BlockId = block.Id;

Loading…
Cancel
Save