diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec747a43..7d27a801 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- If found, ErsatzTV will run ffprobe to get statistics immediately before streaming from disk
- When local files are unavailable, ErsatzTV must be logged into the same Plex server as DizqueTV
- ErsatzTV will ask Plex for statistics immediately before streaming from Plex
+- Add new *experimental* playout type `Block`
+ - **This playout type is under active development and updates may reset or delete related playout data**
+ - Many planned features are missing, incomplete, or result in errors. This is expected.
+ - Block playouts consist of:
+ - `Blocks` - ordered list of items to play within the specified duration
+ - `Templates` - a generic "day" that consists of blocks scheduled at specific times
+ - `Playout Templates` - templates to schedule using the specified criteria. Only one template will be selected each day
+ - Much more to come on this feature as development continues
### Fixed
- Fix error loading path replacements when using MySql
diff --git a/Directory.Build.props b/Directory.Build.props
index c4d1aa6e..649687ae 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,5 +1,6 @@
develop
+ false
\ No newline at end of file
diff --git a/ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings b/ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings
index 0dc00038..8df6a2bf 100644
--- a/ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings
+++ b/ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings
@@ -35,6 +35,8 @@
True
True
True
+ True
+ True
True
True
True
diff --git a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
index 50049074..42b40cc8 100644
--- a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
+++ b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
@@ -22,12 +22,16 @@ public class BuildPlayoutHandler : IRequestHandler _workerChannel;
public BuildPlayoutHandler(
IClient client,
IDbContextFactory dbContextFactory,
IPlayoutBuilder playoutBuilder,
+ IBlockPlayoutBuilder blockPlayoutBuilder,
+ IExternalJsonPlayoutBuilder externalJsonPlayoutBuilder,
IFFmpegSegmenterService ffmpegSegmenterService,
IEntityLocker entityLocker,
ChannelWriter workerChannel)
@@ -35,6 +39,8 @@ public class BuildPlayoutHandler : IRequestHandler p.Channel)
.Include(p => p.Items)
+ .Include(p => p.PlayoutHistory)
+ .Include(p => p.Templates)
+ .ThenInclude(t => t.Template)
+ .ThenInclude(t => t.Items)
+ .ThenInclude(i => i.Block)
+ .ThenInclude(b => b.Items)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.Include(p => p.ProgramScheduleAlternates)
diff --git a/ErsatzTV.Application/Playouts/Commands/CreateBlockPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/CreateBlockPlayoutHandler.cs
new file mode 100644
index 00000000..fe8cf965
--- /dev/null
+++ b/ErsatzTV.Application/Playouts/Commands/CreateBlockPlayoutHandler.cs
@@ -0,0 +1,67 @@
+using System.Threading.Channels;
+using ErsatzTV.Application.Channels;
+using ErsatzTV.Core;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Scheduling;
+using ErsatzTV.Infrastructure.Data;
+using ErsatzTV.Infrastructure.Extensions;
+using Microsoft.EntityFrameworkCore;
+using Channel = ErsatzTV.Core.Domain.Channel;
+
+namespace ErsatzTV.Application.Playouts;
+
+public class CreateBlockPlayoutHandler(
+ ChannelWriter channel,
+ IDbContextFactory dbContextFactory)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ CreateBlockPlayout request,
+ CancellationToken cancellationToken)
+ {
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ Validation validation = await Validate(dbContext, request);
+ return await validation.Apply(playout => PersistPlayout(dbContext, playout));
+ }
+
+ private async Task PersistPlayout(TvContext dbContext, Playout playout)
+ {
+ await dbContext.Playouts.AddAsync(playout);
+ await dbContext.SaveChangesAsync();
+ await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
+ await channel.WriteAsync(new RefreshChannelList());
+ return new CreatePlayoutResponse(playout.Id);
+ }
+
+ private static async Task> Validate(
+ TvContext dbContext,
+ CreateBlockPlayout request) =>
+ (await ValidateChannel(dbContext, request), ValidatePlayoutType(request))
+ .Apply(
+ (channel, playoutType) => new Playout
+ {
+ ChannelId = channel.Id,
+ ProgramSchedulePlayoutType = playoutType
+ });
+
+ private static Task> ValidateChannel(
+ TvContext dbContext,
+ CreateBlockPlayout createBlockPlayout) =>
+ dbContext.Channels
+ .Include(c => c.Playouts)
+ .SelectOneAsync(c => c.Id, c => c.Id == createBlockPlayout.ChannelId)
+ .Map(o => o.ToValidation("Channel does not exist"))
+ .BindT(ChannelMustNotHavePlayouts);
+
+ private static Validation ChannelMustNotHavePlayouts(Channel channel) =>
+ Optional(channel.Playouts.Count)
+ .Filter(count => count == 0)
+ .Map(_ => channel)
+ .ToValidation("Channel already has one playout");
+
+ private static Validation ValidatePlayoutType(
+ CreateBlockPlayout createBlockPlayout) =>
+ Optional(createBlockPlayout.ProgramSchedulePlayoutType)
+ .Filter(playoutType => playoutType == ProgramSchedulePlayoutType.Block)
+ .ToValidation("[ProgramSchedulePlayoutType] must be Block");
+}
diff --git a/ErsatzTV.Application/Playouts/Commands/CreatePlayout.cs b/ErsatzTV.Application/Playouts/Commands/CreatePlayout.cs
index 3ae417a7..bca24a60 100644
--- a/ErsatzTV.Application/Playouts/Commands/CreatePlayout.cs
+++ b/ErsatzTV.Application/Playouts/Commands/CreatePlayout.cs
@@ -9,5 +9,8 @@ public record CreatePlayout(int ChannelId, ProgramSchedulePlayoutType ProgramSch
public record CreateFloodPlayout(int ChannelId, int ProgramScheduleId)
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Flood);
+public record CreateBlockPlayout(int ChannelId)
+ : CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Block);
+
public record CreateExternalJsonPlayout(int ChannelId, string ExternalJsonFile)
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.ExternalJson);
diff --git a/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs
index f0e1345e..7cf5ae46 100644
--- a/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs
+++ b/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs
@@ -25,9 +25,7 @@ public class DeletePlayoutHandler : IRequestHandler> Handle(
- DeletePlayout request,
- CancellationToken cancellationToken)
+ public async Task> Handle(DeletePlayout request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs
index 7f010ddf..3921f2b5 100644
--- a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs
+++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs
@@ -56,7 +56,7 @@ public class GetProgramScheduleItemsHandler :
.Map(psi => EnforceProperties(maybeProgramSchedule, psi)).ToList());
}
- // shuffled schedule items supports a limited set of properly values
+ // shuffled schedule items supports a limited set of property values
private static ProgramScheduleItemViewModel EnforceProperties(
Option maybeProgramSchedule,
ProgramScheduleItemViewModel item)
diff --git a/ErsatzTV.Application/Scheduling/BlockGroupViewModel.cs b/ErsatzTV.Application/Scheduling/BlockGroupViewModel.cs
new file mode 100644
index 00000000..89d8e3a5
--- /dev/null
+++ b/ErsatzTV.Application/Scheduling/BlockGroupViewModel.cs
@@ -0,0 +1,3 @@
+namespace ErsatzTV.Application.Scheduling;
+
+public record BlockGroupViewModel(int Id, string Name, int BlockCount);
diff --git a/ErsatzTV.Application/Scheduling/BlockItemViewModel.cs b/ErsatzTV.Application/Scheduling/BlockItemViewModel.cs
new file mode 100644
index 00000000..5fb3b6c8
--- /dev/null
+++ b/ErsatzTV.Application/Scheduling/BlockItemViewModel.cs
@@ -0,0 +1,15 @@
+using ErsatzTV.Application.MediaCollections;
+using ErsatzTV.Application.MediaItems;
+using ErsatzTV.Core.Domain;
+
+namespace ErsatzTV.Application.Scheduling;
+
+public record BlockItemViewModel(
+ int Id,
+ int Index,
+ ProgramScheduleItemCollectionType CollectionType,
+ MediaCollectionViewModel Collection,
+ MultiCollectionViewModel MultiCollection,
+ SmartCollectionViewModel SmartCollection,
+ NamedMediaItemViewModel MediaItem,
+ PlaybackOrder PlaybackOrder);
diff --git a/ErsatzTV.Application/Scheduling/BlockViewModel.cs b/ErsatzTV.Application/Scheduling/BlockViewModel.cs
new file mode 100644
index 00000000..d0d3023b
--- /dev/null
+++ b/ErsatzTV.Application/Scheduling/BlockViewModel.cs
@@ -0,0 +1,3 @@
+namespace ErsatzTV.Application.Scheduling;
+
+public record BlockViewModel(int Id, string Name, int Minutes);
diff --git a/ErsatzTV.Application/Scheduling/Commands/CreateBlock.cs b/ErsatzTV.Application/Scheduling/Commands/CreateBlock.cs
new file mode 100644
index 00000000..ea56e921
--- /dev/null
+++ b/ErsatzTV.Application/Scheduling/Commands/CreateBlock.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core;
+
+namespace ErsatzTV.Application.Scheduling;
+
+public record CreateBlock(int BlockGroupId, string Name) : IRequest>;
diff --git a/ErsatzTV.Application/Scheduling/Commands/CreateBlockGroup.cs b/ErsatzTV.Application/Scheduling/Commands/CreateBlockGroup.cs
new file mode 100644
index 00000000..06d7fa07
--- /dev/null
+++ b/ErsatzTV.Application/Scheduling/Commands/CreateBlockGroup.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core;
+
+namespace ErsatzTV.Application.Scheduling;
+
+public record CreateBlockGroup(string Name) : IRequest>;
diff --git a/ErsatzTV.Application/Scheduling/Commands/CreateBlockGroupHandler.cs b/ErsatzTV.Application/Scheduling/Commands/CreateBlockGroupHandler.cs
new file mode 100644
index 00000000..13e46693
--- /dev/null
+++ b/ErsatzTV.Application/Scheduling/Commands/CreateBlockGroupHandler.cs
@@ -0,0 +1,31 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Domain.Scheduling;
+using ErsatzTV.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace ErsatzTV.Application.Scheduling;
+
+public class CreateBlockGroupHandler(IDbContextFactory dbContextFactory)
+ : IRequestHandler>
+{
+ public async Task> Handle(CreateBlockGroup request, CancellationToken cancellationToken)
+ {
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ Validation validation = await Validate(request);
+ return await validation.Apply(profile => PersistBlockGroup(dbContext, profile));
+ }
+
+ private static async Task PersistBlockGroup(TvContext dbContext, BlockGroup blockGroup)
+ {
+ await dbContext.BlockGroups.AddAsync(blockGroup);
+ await dbContext.SaveChangesAsync();
+ return Mapper.ProjectToViewModel(blockGroup);
+ }
+
+ private static Task> Validate(CreateBlockGroup request) =>
+ Task.FromResult(ValidateName(request).Map(name => new BlockGroup { Name = name, Blocks = [] }));
+
+ private static Validation ValidateName(CreateBlockGroup createBlockGroup) =>
+ createBlockGroup.NotEmpty(x => x.Name)
+ .Bind(_ => createBlockGroup.NotLongerThan(50)(x => x.Name));
+}
diff --git a/ErsatzTV.Application/Scheduling/Commands/CreateBlockHandler.cs b/ErsatzTV.Application/Scheduling/Commands/CreateBlockHandler.cs
new file mode 100644
index 00000000..2c515516
--- /dev/null
+++ b/ErsatzTV.Application/Scheduling/Commands/CreateBlockHandler.cs
@@ -0,0 +1,40 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Domain.Scheduling;
+using ErsatzTV.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace ErsatzTV.Application.Scheduling;
+
+public class CreateBlockHandler(IDbContextFactory dbContextFactory)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ CreateBlock request,
+ CancellationToken cancellationToken)
+ {
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ Validation validation = await Validate(request);
+ return await validation.Apply(profile => PersistBlock(dbContext, profile));
+ }
+
+ private static async Task PersistBlock(TvContext dbContext, Block block)
+ {
+ await dbContext.Blocks.AddAsync(block);
+ await dbContext.SaveChangesAsync();
+ return Mapper.ProjectToViewModel(block);
+ }
+
+ private static Task> Validate(CreateBlock request) =>
+ Task.FromResult(
+ ValidateName(request).Map(
+ name => new Block
+ {
+ BlockGroupId = request.BlockGroupId,
+ Name = name,
+ Minutes = 30
+ }));
+
+ private static Validation ValidateName(CreateBlock createBlock) =>
+ createBlock.NotEmpty(x => x.Name)
+ .Bind(_ => createBlock.NotLongerThan(50)(x => x.Name));
+}
diff --git a/ErsatzTV.Application/Scheduling/Commands/CreateTemplate.cs b/ErsatzTV.Application/Scheduling/Commands/CreateTemplate.cs
new file mode 100644
index 00000000..c7f5c0df
--- /dev/null
+++ b/ErsatzTV.Application/Scheduling/Commands/CreateTemplate.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core;
+
+namespace ErsatzTV.Application.Scheduling;
+
+public record CreateTemplate(int TemplateGroupId, string Name) : IRequest>;
diff --git a/ErsatzTV.Application/Scheduling/Commands/CreateTemplateGroup.cs b/ErsatzTV.Application/Scheduling/Commands/CreateTemplateGroup.cs
new file mode 100644
index 00000000..03d0cc44
--- /dev/null
+++ b/ErsatzTV.Application/Scheduling/Commands/CreateTemplateGroup.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core;
+
+namespace ErsatzTV.Application.Scheduling;
+
+public record CreateTemplateGroup(string Name) : IRequest>;
diff --git a/ErsatzTV.Application/Scheduling/Commands/CreateTemplateGroupHandler.cs b/ErsatzTV.Application/Scheduling/Commands/CreateTemplateGroupHandler.cs
new file mode 100644
index 00000000..d817b983
--- /dev/null
+++ b/ErsatzTV.Application/Scheduling/Commands/CreateTemplateGroupHandler.cs
@@ -0,0 +1,35 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Domain.Scheduling;
+using ErsatzTV.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace ErsatzTV.Application.Scheduling;
+
+public class CreateTemplateGroupHandler(IDbContextFactory dbContextFactory)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ CreateTemplateGroup request,
+ CancellationToken cancellationToken)
+ {
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ Validation validation = await Validate(request);
+ return await validation.Apply(profile => PersistTemplateGroup(dbContext, profile));
+ }
+
+ private static async Task PersistTemplateGroup(
+ TvContext dbContext,
+ TemplateGroup templateGroup)
+ {
+ await dbContext.TemplateGroups.AddAsync(templateGroup);
+ await dbContext.SaveChangesAsync();
+ return Mapper.ProjectToViewModel(templateGroup);
+ }
+
+ private static Task> Validate(CreateTemplateGroup request) =>
+ Task.FromResult(ValidateName(request).Map(name => new TemplateGroup { Name = name, Templates = [] }));
+
+ private static Validation ValidateName(CreateTemplateGroup createTemplateGroup) =>
+ createTemplateGroup.NotEmpty(x => x.Name)
+ .Bind(_ => createTemplateGroup.NotLongerThan(50)(x => x.Name));
+}
diff --git a/ErsatzTV.Application/Scheduling/Commands/CreateTemplateHandler.cs b/ErsatzTV.Application/Scheduling/Commands/CreateTemplateHandler.cs
new file mode 100644
index 00000000..33f708d3
--- /dev/null
+++ b/ErsatzTV.Application/Scheduling/Commands/CreateTemplateHandler.cs
@@ -0,0 +1,39 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Domain.Scheduling;
+using ErsatzTV.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace ErsatzTV.Application.Scheduling;
+
+public class CreateTemplateHandler(IDbContextFactory dbContextFactory)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ CreateTemplate request,
+ CancellationToken cancellationToken)
+ {
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ Validation validation = await Validate(request);
+ return await validation.Apply(profile => PersistTemplate(dbContext, profile));
+ }
+
+ private static async Task PersistTemplate(TvContext dbContext, Template template)
+ {
+ await dbContext.Templates.AddAsync(template);
+ await dbContext.SaveChangesAsync();
+ return Mapper.ProjectToViewModel(template);
+ }
+
+ private static Task> Validate(CreateTemplate request) =>
+ Task.FromResult(
+ ValidateName(request).Map(
+ name => new Template
+ {
+ TemplateGroupId = request.TemplateGroupId,
+ Name = name
+ }));
+
+ private static Validation ValidateName(CreateTemplate createTemplate) =>
+ createTemplate.NotEmpty(x => x.Name)
+ .Bind(_ => createTemplate.NotLongerThan(50)(x => x.Name));
+}
diff --git a/ErsatzTV.Application/Scheduling/Commands/DeleteBlock.cs b/ErsatzTV.Application/Scheduling/Commands/DeleteBlock.cs
new file mode 100644
index 00000000..190ebd81
--- /dev/null
+++ b/ErsatzTV.Application/Scheduling/Commands/DeleteBlock.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core;
+
+namespace ErsatzTV.Application.Scheduling;
+
+public record DeleteBlock(int BlockId) : IRequest