mirror of https://github.com/ErsatzTV/ErsatzTV.git
17 changed files with 10367 additions and 34 deletions
@ -0,0 +1,172 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.Domain.Scheduling; |
||||||
|
using ErsatzTV.Core.Scheduling; |
||||||
|
using ErsatzTV.Core.Scheduling.BlockScheduling; |
||||||
|
using FluentAssertions; |
||||||
|
using Newtonsoft.Json; |
||||||
|
using NUnit.Framework; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Tests.Scheduling.BlockScheduling; |
||||||
|
|
||||||
|
public static class BlockPlayoutChangeDetectionTests |
||||||
|
{ |
||||||
|
[TestFixture] |
||||||
|
public class FindUpdatedItems |
||||||
|
{ |
||||||
|
// takes playout items, item block keys and effective blocks
|
||||||
|
// returns blocks to schedule and playout items to remove
|
||||||
|
|
||||||
|
// test case: nothing has changed
|
||||||
|
|
||||||
|
// test case: block has moved from one time to another time, nothing after
|
||||||
|
// test case: block has moved from one time to another time, same block after
|
||||||
|
// test case: block has moved from one time to another time, different block with same collection after
|
||||||
|
// test case: block was moved from one time to another time, different block with different collection after
|
||||||
|
|
||||||
|
// test case: block was removed, nothing after
|
||||||
|
// test case: block was removed, same block after
|
||||||
|
// test case: block was removed, different block with same collection after
|
||||||
|
// test case: block was removed, different block with different collection after
|
||||||
|
|
||||||
|
// test case: block was added, nothing after
|
||||||
|
// test case: block was added, same block after
|
||||||
|
// test case: block was added, different block with same collection after
|
||||||
|
// test case: block was added, different block with different collection after
|
||||||
|
|
||||||
|
|
||||||
|
[Test] |
||||||
|
public void Should_Work_When_Nothing_Has_Changed() |
||||||
|
{ |
||||||
|
DateTimeOffset dateUpdated = DateTimeOffset.Now; |
||||||
|
List<Block> blocks = Blocks(dateUpdated); |
||||||
|
var template = new Template { Id = 1, DateUpdated = dateUpdated.UtcDateTime }; |
||||||
|
var playoutTemplate = new PlayoutTemplate { Id = 10, DateUpdated = dateUpdated.UtcDateTime }; |
||||||
|
|
||||||
|
Block block1 = blocks[0]; |
||||||
|
Block block2 = blocks[1]; |
||||||
|
var blockKey1 = new BlockKey(block1, template, playoutTemplate); |
||||||
|
var blockKey2 = new BlockKey(block2, template, playoutTemplate); |
||||||
|
var collectionKey1 = CollectionKey.ForBlockItem(block1.Items.Head()); |
||||||
|
var collectionKey2 = CollectionKey.ForBlockItem(block2.Items.Head()); |
||||||
|
|
||||||
|
// 9am-9:20am
|
||||||
|
PlayoutItem playoutItem1 = PlayoutItem(blockKey1, collectionKey1, GetLocalDate(2024, 1, 17).AddHours(9)); |
||||||
|
// 1pm-1:20pm
|
||||||
|
PlayoutItem playoutItem2 = PlayoutItem(blockKey2, collectionKey2, GetLocalDate(2024, 1, 17).AddHours(13)); |
||||||
|
|
||||||
|
List<PlayoutItem> playoutItems = [playoutItem1, playoutItem2]; |
||||||
|
Dictionary<PlayoutItem, BlockKey> itemBlockKeys = |
||||||
|
new() |
||||||
|
{ |
||||||
|
{ playoutItem1, blockKey1 }, |
||||||
|
{ playoutItem2, blockKey2 } |
||||||
|
}; |
||||||
|
|
||||||
|
List<EffectiveBlock> effectiveBlocks = |
||||||
|
[ |
||||||
|
new EffectiveBlock(block1, blockKey1, GetLocalDate(2024, 1, 17).AddHours(9)), |
||||||
|
new EffectiveBlock(block2, blockKey2, GetLocalDate(2024, 1, 17).AddHours(13)), |
||||||
|
]; |
||||||
|
|
||||||
|
Map<CollectionKey, string> collectionEtags = LanguageExt.Map<CollectionKey, string>.Empty; |
||||||
|
collectionEtags = collectionEtags.Add(collectionKey1, JsonConvert.SerializeObject(collectionKey1)); |
||||||
|
collectionEtags = collectionEtags.Add(collectionKey2, JsonConvert.SerializeObject(collectionKey2)); |
||||||
|
|
||||||
|
Tuple<List<EffectiveBlock>, List<PlayoutItem>> result = BlockPlayoutChangeDetection.FindUpdatedItems( |
||||||
|
playoutItems, |
||||||
|
itemBlockKeys, |
||||||
|
effectiveBlocks, |
||||||
|
collectionEtags); |
||||||
|
|
||||||
|
// nothing to schedule
|
||||||
|
result.Item1.Should().HaveCount(0); |
||||||
|
|
||||||
|
// do not need to remove any playout items or history
|
||||||
|
result.Item2.Should().HaveCount(0); |
||||||
|
} |
||||||
|
|
||||||
|
private static List<Block> Blocks(DateTimeOffset dateUpdated) |
||||||
|
{ |
||||||
|
List<Block> blocks = |
||||||
|
[ |
||||||
|
// SHOW A
|
||||||
|
new Block |
||||||
|
{ |
||||||
|
Id = 1, |
||||||
|
Items = |
||||||
|
[ |
||||||
|
new BlockItem |
||||||
|
{ |
||||||
|
Id = 1, |
||||||
|
Index = 1, |
||||||
|
BlockId = 1, |
||||||
|
CollectionType = ProgramScheduleItemCollectionType.TelevisionShow, |
||||||
|
MediaItemId = 1, |
||||||
|
PlaybackOrder = PlaybackOrder.Chronological |
||||||
|
} |
||||||
|
], |
||||||
|
DateUpdated = dateUpdated.UtcDateTime |
||||||
|
}, |
||||||
|
// SHOW B
|
||||||
|
new Block |
||||||
|
{ |
||||||
|
Id = 2, |
||||||
|
Items = |
||||||
|
[ |
||||||
|
new BlockItem |
||||||
|
{ |
||||||
|
Id = 2, |
||||||
|
Index = 1, |
||||||
|
BlockId = 2, |
||||||
|
CollectionType = ProgramScheduleItemCollectionType.TelevisionShow, |
||||||
|
MediaItemId = 2, |
||||||
|
PlaybackOrder = PlaybackOrder.Chronological |
||||||
|
} |
||||||
|
], |
||||||
|
DateUpdated = dateUpdated.UtcDateTime |
||||||
|
}, |
||||||
|
// SHOW C
|
||||||
|
new Block |
||||||
|
{ |
||||||
|
Id = 3, |
||||||
|
Items = |
||||||
|
[ |
||||||
|
new BlockItem |
||||||
|
{ |
||||||
|
Id = 3, |
||||||
|
Index = 1, |
||||||
|
BlockId = 3, |
||||||
|
CollectionType = ProgramScheduleItemCollectionType.TelevisionShow, |
||||||
|
MediaItemId = 3, |
||||||
|
PlaybackOrder = PlaybackOrder.Chronological |
||||||
|
} |
||||||
|
], |
||||||
|
DateUpdated = dateUpdated.UtcDateTime |
||||||
|
} |
||||||
|
]; |
||||||
|
|
||||||
|
foreach (Block block in blocks) |
||||||
|
{ |
||||||
|
foreach (BlockItem blockItem in block.Items) |
||||||
|
{ |
||||||
|
blockItem.Block = block; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return blocks; |
||||||
|
} |
||||||
|
|
||||||
|
private static PlayoutItem PlayoutItem(BlockKey blockKey, CollectionKey collectionKey, DateTimeOffset start) => |
||||||
|
new() |
||||||
|
{ |
||||||
|
Start = start.UtcDateTime, |
||||||
|
Finish = start.UtcDateTime.AddMinutes(20), |
||||||
|
BlockKey = JsonConvert.SerializeObject(blockKey), |
||||||
|
CollectionKey = JsonConvert.SerializeObject(collectionKey), |
||||||
|
CollectionEtag = JsonConvert.SerializeObject(collectionKey) |
||||||
|
}; |
||||||
|
|
||||||
|
private static DateTimeOffset GetLocalDate(int year, int month, int day) => |
||||||
|
new(year, month, day, 0, 0, 0, TimeSpan.FromHours(-6)); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,109 @@ |
|||||||
|
using ErsatzTV.Core.Domain.Scheduling; |
||||||
|
using ErsatzTV.Core.Scheduling.BlockScheduling; |
||||||
|
using FluentAssertions; |
||||||
|
using NUnit.Framework; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Tests.Scheduling.BlockScheduling; |
||||||
|
|
||||||
|
public static class EffectiveBlockTests |
||||||
|
{ |
||||||
|
[TestFixture] |
||||||
|
public class GetEffectiveBlocks |
||||||
|
{ |
||||||
|
[Test] |
||||||
|
public void Should_Work_With_No_Matching_Days() |
||||||
|
{ |
||||||
|
DateTimeOffset now = DateTimeOffset.Now; |
||||||
|
|
||||||
|
List<PlayoutTemplate> templates = |
||||||
|
[ |
||||||
|
new PlayoutTemplate |
||||||
|
{ |
||||||
|
Index = 1, |
||||||
|
DaysOfWeek = [DayOfWeek.Sunday], |
||||||
|
DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(), |
||||||
|
MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(), |
||||||
|
Template = SingleBlockTemplate(now), |
||||||
|
DateUpdated = now.UtcDateTime |
||||||
|
} |
||||||
|
]; |
||||||
|
|
||||||
|
DateTimeOffset start = GetLocalDate(2024, 1, 15).AddHours(9); |
||||||
|
|
||||||
|
List<EffectiveBlock> result = EffectiveBlock.GetEffectiveBlocks(templates, start, daysToBuild: 5); |
||||||
|
|
||||||
|
result.Should().HaveCount(0); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public void Should_Work_With_Blank_Days() |
||||||
|
{ |
||||||
|
DateTimeOffset now = DateTimeOffset.Now; |
||||||
|
|
||||||
|
List<PlayoutTemplate> templates = |
||||||
|
[ |
||||||
|
new PlayoutTemplate |
||||||
|
{ |
||||||
|
Index = 1, |
||||||
|
DaysOfWeek = [DayOfWeek.Monday, DayOfWeek.Wednesday, DayOfWeek.Friday], |
||||||
|
DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(), |
||||||
|
MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(), |
||||||
|
Template = SingleBlockTemplate(now), |
||||||
|
DateUpdated = now.UtcDateTime |
||||||
|
} |
||||||
|
]; |
||||||
|
|
||||||
|
DateTimeOffset start = GetLocalDate(2024, 1, 15).AddHours(9); |
||||||
|
|
||||||
|
List<EffectiveBlock> result = EffectiveBlock.GetEffectiveBlocks(templates, start, daysToBuild: 5); |
||||||
|
|
||||||
|
result.Should().HaveCount(3); |
||||||
|
|
||||||
|
result[0].Start.DayOfWeek.Should().Be(DayOfWeek.Monday); |
||||||
|
result[0].Start.Date.Should().Be(GetLocalDate(2024, 1, 15).Date); |
||||||
|
|
||||||
|
result[1].Start.DayOfWeek.Should().Be(DayOfWeek.Wednesday); |
||||||
|
result[1].Start.Date.Should().Be(GetLocalDate(2024, 1, 17).Date); |
||||||
|
|
||||||
|
result[2].Start.DayOfWeek.Should().Be(DayOfWeek.Friday); |
||||||
|
result[2].Start.Date.Should().Be(GetLocalDate(2024, 1, 19).Date); |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: test when clocks spring forward
|
||||||
|
// TODO: test when clocks fall back
|
||||||
|
|
||||||
|
// TODO: offset may be incorrect on days with time change, since start offset is re-used
|
||||||
|
} |
||||||
|
|
||||||
|
private static DateTimeOffset GetLocalDate(int year, int month, int day) => |
||||||
|
new(year, month, day, 0, 0, 0, TimeSpan.FromHours(-6)); |
||||||
|
|
||||||
|
private static Template SingleBlockTemplate(DateTimeOffset dateUpdated) |
||||||
|
{ |
||||||
|
var template = new Template |
||||||
|
{ |
||||||
|
Id = 1, |
||||||
|
Items = |
||||||
|
[ |
||||||
|
new TemplateItem |
||||||
|
{ |
||||||
|
Block = new Block |
||||||
|
{ |
||||||
|
Id = 1, |
||||||
|
DateUpdated = dateUpdated.UtcDateTime |
||||||
|
}, |
||||||
|
StartTime = TimeSpan.FromHours(9) |
||||||
|
} |
||||||
|
], |
||||||
|
DateUpdated = dateUpdated.UtcDateTime |
||||||
|
}; |
||||||
|
|
||||||
|
// this is used for navigation
|
||||||
|
foreach (TemplateItem item in template.Items) |
||||||
|
{ |
||||||
|
item.Template = template; |
||||||
|
} |
||||||
|
|
||||||
|
return template; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Interfaces.Metadata; |
||||||
|
|
||||||
|
public interface ICollectionEtag |
||||||
|
{ |
||||||
|
string ForCollectionItems(List<MediaItem> items); |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.MySql.Migrations |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Add_PlayoutItemCollectionKey : Migration |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "CollectionEtag", |
||||||
|
table: "PlayoutItem", |
||||||
|
type: "longtext", |
||||||
|
nullable: true) |
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "CollectionKey", |
||||||
|
table: "PlayoutItem", |
||||||
|
type: "longtext", |
||||||
|
nullable: true) |
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "CollectionEtag", |
||||||
|
table: "PlayoutItem"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "CollectionKey", |
||||||
|
table: "PlayoutItem"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,38 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Sqlite.Migrations |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Add_PlayoutItemCollectionKey : Migration |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "CollectionEtag", |
||||||
|
table: "PlayoutItem", |
||||||
|
type: "TEXT", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "CollectionKey", |
||||||
|
table: "PlayoutItem", |
||||||
|
type: "TEXT", |
||||||
|
nullable: true); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "CollectionEtag", |
||||||
|
table: "PlayoutItem"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "CollectionKey", |
||||||
|
table: "PlayoutItem"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using System.Security.Cryptography; |
||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.Interfaces.Metadata; |
||||||
|
using Microsoft.IO; |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Metadata; |
||||||
|
|
||||||
|
[SuppressMessage("Security", "CA5350:Do Not Use Weak Cryptographic Algorithms")] |
||||||
|
public class CollectionEtag : ICollectionEtag |
||||||
|
{ |
||||||
|
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; |
||||||
|
|
||||||
|
public CollectionEtag(RecyclableMemoryStreamManager recyclableMemoryStreamManager) => |
||||||
|
_recyclableMemoryStreamManager = recyclableMemoryStreamManager; |
||||||
|
|
||||||
|
public string ForCollectionItems(List<MediaItem> items) |
||||||
|
{ |
||||||
|
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream(); |
||||||
|
using var bw = new BinaryWriter(ms); |
||||||
|
|
||||||
|
foreach (MediaItem item in items.OrderBy(i => i.Id)) |
||||||
|
{ |
||||||
|
bw.Write(item.Id); |
||||||
|
} |
||||||
|
|
||||||
|
ms.Position = 0; |
||||||
|
byte[] hash = SHA1.Create().ComputeHash(ms); |
||||||
|
return BitConverter.ToString(hash).Replace("-", string.Empty); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue