feat(74): per-channel On Now/Next transient overlay #569

Merged
timothy merged 10 commits from feat/74-on-now-next-overlay into main 2026-07-22 22:36:42 +02:00
45 changed files with 16530 additions and 43 deletions
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -32,4 +32,5 @@ public record UpdateChannel(
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg) : IRequest<Either<BaseError, ChannelViewModel>>;
bool ShowInEpg,
List<int> GraphicsElementIds) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -34,6 +34,7 @@ public class UpdateChannelHandler(
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.Include(c => c.Playouts)
.Include(c => c.ChannelGraphicsElements)
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
return await maybeChannel.Match(
@@ -173,6 +174,14 @@ public class UpdateChannelHandler(
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
c.ChannelGraphicsElements ??= [];
var desired = update.GraphicsElementIds?.Distinct().ToList() ?? [];
c.ChannelGraphicsElements.RemoveAll(cge => !desired.Contains(cge.GraphicsElementId));
foreach (int id in desired.Where(id => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != id)))
{
c.ChannelGraphicsElements.Add(new ChannelGraphicsElement { ChannelId = c.Id, GraphicsElementId = id });
}
await dbContext.SaveChangesAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
+2 -1
View File
@@ -93,7 +93,8 @@ internal static class Mapper
channel.TranscodeMode,
channel.IdleBehavior,
channel.IsEnabled,
channel.ShowInEpg);
channel.ShowInEpg,
channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? []);
}
internal static ChannelResponseModel ProjectToResponseModel(
@@ -1,5 +1,6 @@
using ErsatzTV.Core.Api.Graphics;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Graphics.Mapper;
@@ -18,10 +19,14 @@ public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> db
.AsNoTracking()
.ToListAsync(cancellationToken);
return graphicsElements
.Map(ProjectToViewModel)
.OrderBy(e => e.Name == e.FileName)
.ThenBy(e => e.Name)
.Select(vm => new GraphicsElementResponseModel(vm.Id, vm.Name))
.Select(e => new
{
Vm = ProjectToViewModel(e),
BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
})
.OrderBy(x => x.Vm.Name == x.Vm.FileName)
.ThenBy(x => x.Vm.Name)
.Select(x => new GraphicsElementResponseModel(x.Vm.Id, x.Vm.Name, x.BuiltIn))
.ToList();
}
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -60,6 +60,8 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
.ThenInclude(p => p.Resolution)
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.Include(c => c.ChannelGraphicsElements)
.ThenInclude(x => x.GraphicsElement)
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
foreach (var channel in maybeChannel)
@@ -38,7 +38,8 @@ public record ChannelDetailResponseModel(
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg);
bool ShowInEpg,
int[] GraphicsElementIds);
// Wire-compatible mirror of the Application-layer ArtworkContentTypeModel (which lives in
// ErsatzTV.Application and therefore can't be referenced from Core). Same serialized shape the
@@ -1,4 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Graphics;
public record GraphicsElementResponseModel(int Id, string Name);
public record GraphicsElementResponseModel(int Id, string Name, bool BuiltIn);
+2
View File
@@ -25,6 +25,8 @@ public class Channel
public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; }
public List<GraphicsElement> GraphicsElements { get; set; }
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
public ChannelStreamSelectorMode StreamSelectorMode { get; set; }
public string StreamSelector { get; set; }
public string PreferredAudioLanguageCode { get; set; }
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain;
public class ChannelGraphicsElement
{
public int ChannelId { get; set; }
public Channel Channel { get; set; }
public int GraphicsElementId { get; set; }
public GraphicsElement GraphicsElement { get; set; }
}
+1
View File
@@ -25,6 +25,7 @@ public class ConfigElementKey
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id");
public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id");
public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded");
public static ConfigElementKey GraphicsOnNowNextSeeded => new("graphics.on_now_next_seeded");
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
+2
View File
@@ -16,6 +16,8 @@ public class GraphicsElement
public List<BlockItemGraphicsElement> BlockItemGraphicsElements { get; set; }
public List<Deco> Decos { get; set; }
public List<DecoGraphicsElement> DecoGraphicsElements { get; set; }
public List<Channel> Channels { get; set; }
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
// for unit testing
public override string ToString() => Path;
@@ -135,6 +135,15 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic
result.AddRange(playoutItem.PlayoutItemGraphicsElements);
// channel-level overlays are a base layer: merged with playout-item / Merge-deco elements,
// but suppressed by a deco in Override/Disable mode (which returns before reaching here).
if (channel.ChannelGraphicsElements is not null)
{
result.AddRange(
channel.ChannelGraphicsElements.Map(cge =>
new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = cge.GraphicsElement }));
}
return result;
}
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Graphics;
public static class GraphicsElementDefaults
{
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
public const string OnNowNextFileName = "on-now-next.yml";
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelGraphicsElement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ChannelGraphicsElement",
columns: table => new
{
ChannelId = table.Column<int>(type: "int", nullable: false),
GraphicsElementId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelGraphicsElement", x => new { x.ChannelId, x.GraphicsElementId });
table.ForeignKey(
name: "FK_ChannelGraphicsElement_Channel_ChannelId",
column: x => x.ChannelId,
principalTable: "Channel",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId",
column: x => x.GraphicsElementId,
principalTable: "GraphicsElement",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ChannelGraphicsElement_GraphicsElementId",
table: "ChannelGraphicsElement",
column: "GraphicsElementId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelGraphicsElement");
}
}
}
@@ -403,6 +403,21 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("Channel", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelGraphicsElement", b =>
{
b.Property<int>("ChannelId")
.HasColumnType("int");
b.Property<int>("GraphicsElementId")
.HasColumnType("int");
b.HasKey("ChannelId", "GraphicsElementId");
b.HasIndex("GraphicsElementId");
b.ToTable("ChannelGraphicsElement");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
{
b.Property<int>("Id")
@@ -4733,6 +4748,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Watermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelGraphicsElement", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
.WithMany("ChannelGraphicsElements")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.GraphicsElement", "GraphicsElement")
.WithMany("ChannelGraphicsElements")
.HasForeignKey("GraphicsElementId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Channel");
b.Navigation("GraphicsElement");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
@@ -6778,6 +6812,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
{
b.Navigation("Artwork");
b.Navigation("ChannelGraphicsElements");
b.Navigation("Playouts");
});
@@ -6824,6 +6860,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
{
b.Navigation("BlockItemGraphicsElements");
b.Navigation("ChannelGraphicsElements");
b.Navigation("DecoGraphicsElements");
b.Navigation("PlayoutItemGraphicsElements");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelGraphicsElement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ChannelGraphicsElement",
columns: table => new
{
ChannelId = table.Column<int>(type: "INTEGER", nullable: false),
GraphicsElementId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelGraphicsElement", x => new { x.ChannelId, x.GraphicsElementId });
table.ForeignKey(
name: "FK_ChannelGraphicsElement_Channel_ChannelId",
column: x => x.ChannelId,
principalTable: "Channel",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId",
column: x => x.GraphicsElementId,
principalTable: "GraphicsElement",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ChannelGraphicsElement_GraphicsElementId",
table: "ChannelGraphicsElement",
column: "GraphicsElementId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelGraphicsElement");
}
}
}
@@ -390,6 +390,21 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("Channel", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelGraphicsElement", b =>
{
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<int>("GraphicsElementId")
.HasColumnType("INTEGER");
b.HasKey("ChannelId", "GraphicsElementId");
b.HasIndex("GraphicsElementId");
b.ToTable("ChannelGraphicsElement");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
{
b.Property<int>("Id")
@@ -4558,6 +4573,25 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Watermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelGraphicsElement", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
.WithMany("ChannelGraphicsElements")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.GraphicsElement", "GraphicsElement")
.WithMany("ChannelGraphicsElements")
.HasForeignKey("GraphicsElementId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Channel");
b.Navigation("GraphicsElement");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
@@ -6603,6 +6637,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
b.Navigation("Artwork");
b.Navigation("ChannelGraphicsElements");
b.Navigation("Playouts");
});
@@ -6649,6 +6685,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
b.Navigation("BlockItemGraphicsElements");
b.Navigation("ChannelGraphicsElements");
b.Navigation("DecoGraphicsElements");
b.Navigation("PlayoutItemGraphicsElements");
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -53,5 +53,18 @@ public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
.HasForeignKey(i => i.MirrorSourceChannelId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
builder.HasMany(c => c.GraphicsElements)
.WithMany(m => m.Channels)
.UsingEntity<ChannelGraphicsElement>(
j => j.HasOne(ci => ci.GraphicsElement)
.WithMany(mi => mi.ChannelGraphicsElements)
.HasForeignKey(ci => ci.GraphicsElementId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasOne(ci => ci.Channel)
.WithMany(c => c.ChannelGraphicsElements)
.HasForeignKey(ci => ci.ChannelId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(ci => new { ci.ChannelId, ci.GraphicsElementId }));
}
}
@@ -17,6 +17,8 @@ public class ChannelRepository(IDbContextFactory<TvContext> dbContextFactory) :
.Include(c => c.Playouts)
.Include(c => c.MirrorSourceChannel)
.ThenInclude(mc => mc.Playouts)
.Include(c => c.ChannelGraphicsElements)
.ThenInclude(x => x.GraphicsElement)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
@@ -0,0 +1,79 @@
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public static class GraphicsElementSeeder
{
private const string OnNowNextYaml =
"""
name: On Now / Next
epg_entries: 2
location: BottomLeft
horizontal_margin_percent: 4
vertical_margin_percent: 8
width_percent: 42
text_fit: Wrap
text_align: Left
z_index: 100
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
base_style: now
styles:
- name: now
font_size: 30
font_weight: 700
text_color: "#FFFFFF"
halo_color: "#000000"
halo_width: 2
- name: sub
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 2
- name: next
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 2
text: |
[now]NOW {{ Epg[0].Title }}[/now]
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
{{ if Epg.size > 1 }}[next]NEXT {{ Epg[1].Title }} · {{ format_datetime (convert_timezone Epg[1].Start) "h:mm tt" }}[/next]{{ end }}
""";
public static async Task SeedOnNowNext(TvContext context, IFileSystem fileSystem, CancellationToken cancellationToken)
{
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
if (alreadySeeded)
{
return;
}
string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);
if (!fileSystem.Directory.Exists(folder))
{
fileSystem.Directory.CreateDirectory(folder);
}
// Adopt an operator's existing file untouched; only write when absent.
if (!fileSystem.File.Exists(target))
{
await fileSystem.File.WriteAllTextAsync(target, OnNowNextYaml, cancellationToken);
}
await context.ConfigElements.AddAsync(
new ConfigElement { Key = seededKey, Value = "true" },
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,76 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
{
private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
private async Task<(int ElementAId, int ElementBId)> SeedGraphicsElements()
{
await using TvContext context = Db.CreateContext();
var elementA = new GraphicsElement { Path = "element-a.yml" };
var elementB = new GraphicsElement { Path = "element-b.yml" };
context.GraphicsElements.AddRange(elementA, elementB);
await context.SaveChangesAsync();
return (elementA.Id, elementB.Id);
}
[Test]
public async Task Should_Reconcile_GraphicsElement_Join_Add_Then_Remove()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, int elementBId) = await SeedGraphicsElements();
Either<BaseError, ChannelViewModel> addResult = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId, elementBId]),
CancellationToken.None);
addResult.IsRight.ShouldBeTrue();
await using (TvContext context = Db.CreateContext())
{
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId)
.OrderBy(id => id)
.ShouldBe(new[] { elementAId, elementBId }.OrderBy(id => id));
}
Either<BaseError, ChannelViewModel> removeResult = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
CancellationToken.None);
removeResult.IsRight.ShouldBeTrue();
await using (TvContext context = Db.CreateContext())
{
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe(new[] { elementAId });
}
Either<BaseError, ChannelViewModel> clearResult = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: []),
CancellationToken.None);
clearResult.IsRight.ShouldBeTrue();
await using (TvContext context = Db.CreateContext())
{
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
}
}
}
@@ -561,7 +561,8 @@ public class ChannelControllerTests
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false);
false,
[]);
private static ChannelViewModel MakeVm(int id) =>
new(
@@ -677,5 +678,6 @@ public class ChannelControllerTests
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false);
false,
[]);
}
@@ -51,8 +51,8 @@ public class GraphicsElementControllerTests
{
List<GraphicsElementResponseModel> models =
[
new GraphicsElementResponseModel(1, "Lower Third (lower-third.png)"),
new GraphicsElementResponseModel(2, "bug.png")
new GraphicsElementResponseModel(1, "Lower Third (lower-third.png)", false),
new GraphicsElementResponseModel(2, "bug.png", false)
];
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
.Returns(models);
@@ -104,7 +104,8 @@ public class OpenApiSerializerContractTests
default,
default,
true,
true);
true,
[1]);
private static FFmpegSettingsResponseModel FullyPopulatedFFmpegSettings() => new(
"/usr/bin/ffmpeg",
@@ -0,0 +1,73 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Core.FFmpeg;
[TestFixture]
public class GraphicsElementSelectorTests
{
private static (GraphicsElementSelector sel, IDecoSelector deco) Build(DecoEntries entries)
{
var deco = Substitute.For<IDecoSelector>();
deco.GetDecoEntries(Arg.Any<Playout>(), Arg.Any<DateTimeOffset>()).Returns(entries);
return (new GraphicsElementSelector(deco, NullLogger<GraphicsElementSelector>.Instance), deco);
}
private static Channel ChannelWithElement(int elementId, StreamingMode mode = StreamingMode.HttpLiveStreamingSegmenter)
{
var element = new GraphicsElement { Id = elementId, Path = "/x/on-now-next.yml", Kind = GraphicsElementKind.Text };
return new Channel(Guid.NewGuid())
{
StreamingMode = mode,
ChannelGraphicsElements = [new ChannelGraphicsElement { GraphicsElementId = elementId, GraphicsElement = element }]
};
}
private static PlayoutItem Item() => new()
{
Playout = new Playout(),
FillerKind = FillerKind.None,
PlayoutItemGraphicsElements = []
};
[Test]
public void Channel_Element_Is_Emitted_When_No_Deco()
{
(GraphicsElementSelector sel, _) = Build(new DecoEntries(Option<Deco>.None, Option<Deco>.None));
List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(ChannelWithElement(7), Item(), DateTimeOffset.Now);
result.Count.ShouldBe(1);
result[0].GraphicsElement.Id.ShouldBe(7);
}
[Test]
public void Channel_Element_Is_Suppressed_On_HlsDirect()
{
(GraphicsElementSelector sel, _) = Build(new DecoEntries(Option<Deco>.None, Option<Deco>.None));
List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(
ChannelWithElement(7, StreamingMode.HttpLiveStreamingDirect), Item(), DateTimeOffset.Now);
result.ShouldBeEmpty();
}
[Test]
public void Channel_Element_Is_Suppressed_By_Disable_Deco()
{
var deco = new Deco { GraphicsElementsMode = DecoMode.Disable, DecoGraphicsElements = [] };
(GraphicsElementSelector sel, _) = Build(new DecoEntries(deco, Option<Deco>.None));
List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(ChannelWithElement(7), Item(), DateTimeOffset.Now);
result.ShouldBeEmpty();
}
}
@@ -0,0 +1,58 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Infrastructure;
[TestFixture]
public class ChannelGraphicsElementPersistenceTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Channel_Can_Attach_And_Read_Back_A_Graphics_Element()
{
int channelId;
int elementId;
await using (TvContext context = _db.CreateContext())
{
var profile = new FFmpegProfile { Name = "p" };
await context.FFmpegProfiles.AddAsync(profile);
var element = new GraphicsElement { Path = "/x/on-now-next.yml", Name = "On Now / Next", Kind = GraphicsElementKind.Text };
await context.GraphicsElements.AddAsync(element);
await context.SaveChangesAsync();
var channel = new Channel(Guid.NewGuid())
{
Number = "1", Name = "c", Group = "g", FFmpegProfileId = profile.Id,
ChannelGraphicsElements = [new ChannelGraphicsElement { GraphicsElementId = element.Id }]
};
await context.Channels.AddAsync(channel);
await context.SaveChangesAsync();
channelId = channel.Id;
elementId = element.Id;
}
await using (TvContext context = _db.CreateContext())
{
Channel loaded = await context.Channels
.Include(c => c.ChannelGraphicsElements)
.ThenInclude(x => x.GraphicsElement)
.SingleAsync(c => c.Id == channelId);
loaded.ChannelGraphicsElements.Count.ShouldBe(1);
loaded.ChannelGraphicsElements[0].GraphicsElementId.ShouldBe(elementId);
loaded.ChannelGraphicsElements[0].GraphicsElement.Name.ShouldBe("On Now / Next");
}
}
}
@@ -0,0 +1,68 @@
using System.IO.Abstractions;
using Testably.Abstractions.Testing;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Infrastructure;
[TestFixture]
public class GraphicsElementSeederTests
{
private InMemoryTvContext _db = null!;
private string _seededPath = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_seededPath = Path.Combine(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "on-now-next.yml");
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Seeds_File_And_Marker_When_Absent()
{
var fs = new MockFileSystem();
await using TvContext context = _db.CreateContext();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
fs.File.Exists(_seededPath).ShouldBeTrue();
fs.File.ReadAllText(_seededPath).ShouldContain("epg_entries: 2");
(await context.ConfigElements.AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextSeeded.Key)).ShouldBeTrue();
}
[Test]
public async Task Adopts_Existing_File_Untouched()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
await fs.File.WriteAllTextAsync(_seededPath, "name: Operator Custom\nepg_entries: 2\n");
await using TvContext context = _db.CreateContext();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
fs.File.ReadAllText(_seededPath).ShouldContain("Operator Custom");
}
[Test]
public async Task Does_Not_Resurrect_After_Delete()
{
var fs = new MockFileSystem();
await using TvContext context = _db.CreateContext();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
fs.File.Delete(_seededPath);
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
fs.File.Exists(_seededPath).ShouldBeFalse();
}
}
@@ -126,7 +126,8 @@ public abstract class ChannelHandlerTestBase
bool showInEpg = false,
string logoPath = "",
string name = "Test",
string group = "ErsatzTV") =>
string group = "ErsatzTV",
List<int> graphicsElementIds = null) =>
new(
channelId,
name,
@@ -155,5 +156,6 @@ public abstract class ChannelHandlerTestBase
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
isEnabled,
showInEpg);
showInEpg,
graphicsElementIds ?? []);
}
@@ -35,7 +35,8 @@ public record UpdateChannelRequest(
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg)
bool ShowInEpg,
List<int> GraphicsElementIds)
{
public UpdateChannel ToCommand(int channelId) =>
new(
@@ -66,5 +67,6 @@ public record UpdateChannelRequest(
TranscodeMode,
IdleBehavior,
IsEnabled,
ShowInEpg);
ShowInEpg,
GraphicsElementIds ?? []);
}
@@ -1,7 +1,8 @@
using System.Reflection;
using System.Reflection;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@@ -88,6 +89,9 @@ public class DatabaseMigratorService : BackgroundService
_logger.LogInformation("Initializing database");
await DbInitializer.Initialize(dbContext, stoppingToken);
var fileSystem = scope.ServiceProvider.GetRequiredService<System.IO.Abstractions.IFileSystem>();
await GraphicsElementSeeder.SeedOnNowNext(dbContext, fileSystem, stoppingToken);
_systemStartup.DatabaseIsReady();
_logger.LogInformation("Done applying database migrations");
+26 -3
View File
@@ -23611,7 +23611,8 @@
"transcodeMode",
"idleBehavior",
"isEnabled",
"showInEpg"
"showInEpg",
"graphicsElementIds"
],
"type": "object",
"properties": {
@@ -23724,6 +23725,13 @@
},
"showInEpg": {
"type": "boolean"
},
"graphicsElementIds": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
}
}
},
@@ -26710,7 +26718,8 @@
"GraphicsElementResponseModel": {
"required": [
"id",
"name"
"name",
"builtIn"
],
"type": "object",
"properties": {
@@ -26720,6 +26729,9 @@
},
"name": {
"type": "string"
},
"builtIn": {
"type": "boolean"
}
}
},
@@ -31378,7 +31390,8 @@
"transcodeMode",
"idleBehavior",
"isEnabled",
"showInEpg"
"showInEpg",
"graphicsElementIds"
],
"type": "object",
"properties": {
@@ -31510,6 +31523,16 @@
},
"showInEpg": {
"type": "boolean"
},
"graphicsElementIds": {
"type": [
"null",
"array"
],
"items": {
"type": "integer",
"format": "int32"
}
}
}
},
+6
View File
@@ -728,6 +728,12 @@ for items that have no group, so the SPA can render an "ungrouped" bucket
concept elsewhere, this is the established pattern to follow — but be aware it means `Id` is not a
reliable real-entity id for those synthetic rows.
**Channel graphics (issue #74)**: `ChannelDetailResponseModel`/`UpdateChannelRequest` carry
`graphicsElementIds` (the channel's attached `GraphicsElement` ids, reconciled add/remove on PUT via
`Channel.ChannelGraphicsElements`), and `GraphicsElementResponseModel` exposes a server-derived
`builtIn` (`Path.GetFileName(element.Path) == GraphicsElementDefaults.OnNowNextFileName`) — never
client-settable.
## 9. Authentication — session-or-key posture (fail-closed)
The whole `/api` surface is gated by the global `ApiAuthorizationFilter` (renamed from
+38
View File
@@ -203,3 +203,41 @@ bounded render-time fetch that preceded caching) is in `docs/decisions.md` under
Note: `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` for watermark logo
fetching — see issue #1 for details.
## On Now / Next overlay (#74)
A transient "On Now / Next" text bug — the current program (title + episode/subtitle) and the
next program (title + start time) — burned onto a channel's transcoded stream for a few seconds
at each program transition, for a channel-surf feel. Toggled per channel at the channel editor's
**Branding** tab, "Show On Now / Next overlay" switch, beside the logo-bug toggle.
This is a **graphics element** (`GraphicsElement`, `Text` kind), not a watermark — the existing
watermark system is image-only and a channel already uses its one `WatermarkId` for the #67 logo
bug, so multi-line EPG text needed the separate graphics-element system, which previously had no
channel-level attachment. #74 added one: `ChannelGraphicsElement`, a join table structurally
identical to the existing `PlayoutItemGraphicsElement`/`ProgramScheduleItemGraphicsElement`/
`BlockItemGraphicsElement`/`DecoGraphicsElement` joins. See `docs/domain-model.md` → "Graphics
element" and `docs/decisions.md``graphics.channel-level-attachment`.
- **Seeded, built-in element.** A text graphics element YAML (`on-now-next.yml`) is written to the
graphics-elements templates folder and its `GraphicsElement` row created once per database
(`GraphicsElementSeeder.SeedOnNowNext`, guarded by the `graphics.on_now_next_seeded`
`ConfigElement` marker) — mirroring the #67 watermark-preset seed's adopt-not-clobber semantics.
The API marks it with `GraphicsElementResponseModel.builtIn = true` so the SPA finds it reliably
rather than matching on name.
- **Toggle mechanics.** Turning the switch on adds the built-in element's id to the channel's
`graphicsElementIds` (`ChannelDetailResponseModel`/update DTO); turning it off removes it. This
follows the same pattern as the existing "Use logo as on-screen bug" watermark toggle.
- **EPG source is the same cached guide the channel serves** — the overlay reads the cached XMLTV
fragment (`ChannelGuideCacheFolder/{number}.xml`), same as the guide grid, so it is only as fresh
as that cache. On-demand/time-shifted channels can lag until the guide is refreshed on thaw (see
On-demand resume, above); a normal continuous channel stays in sync.
- **Does not apply to HLS-Direct.** `StreamingMode.HttpLiveStreamingDirect` streams the source
file(s) without transcoding, so there is no frame pipeline to burn text into — the selector
always returns no graphics elements in that mode, and the editor's toggle is disabled with an
explanatory caption when the channel is in HLS-Direct.
- **A deco can suppress it.** A `Deco` in `Override` or `Disable` mode for its graphics-elements
section takes precedence over the channel-level overlay (channel elements are a base layer, not
the final word). Additionally, on a **filler** item a deco whose graphics-elements section is not
set to run during filler (`UseGraphicsElementsDuringFiller` false) also clears the overlay, for
`Merge` and `Override` alike — see `docs/domain-model.md` → "Graphics element".
+47
View File
@@ -2713,6 +2713,53 @@ shared row already delivers the user-visible behavior with no schema change.
**Accepted trade-off:** every channel on the shared preset shares one geometry; per-channel tweaks mean
creating a second preset on the Watermarks screen.
## 2026-07-22 — Channel-level graphics-element attachment + seeded On Now/Next text element (#74)
`key: graphics.channel-level-attachment` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none`
**Rule:** A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box.
**Signals:** ChannelGraphicsElement, Channel graphics attachment, GraphicsElementSelector base layer, on-now-next seeded element, GraphicsElementDefaults.OnNowNextFileName, builtIn discriminator · paths: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`, `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`, `ConfigElementKey.GraphicsOnNowNextSeeded`, `GraphicsElementResponseModel.BuiltIn` · issues: #74
#74 asked for a transient "On Now / Next" text bug burned onto the transcoded stream at each
program transition. The rendering and EPG-template-data infrastructure already existed (upstream
graphics engine + our #502/#511 remote-image/graphics-engine work); the gap was that graphics
elements had **no channel-level attachment** — only `PlayoutItem`/`ProgramScheduleItem`/`BlockItem`/
`Deco` joins existed — and there was **no seeded/built-in graphics element**, unlike watermarks.
**Decision: add a 5th join table, `ChannelGraphicsElement`, rather than reuse the watermark FK.**
Watermarks and graphics elements are separate parallel systems; a channel already has exactly one
`WatermarkId`, already spent on the #67 logo bug, and multi-line EPG text is a poor fit for the
single-image watermark model. `ChannelGraphicsElement` is structurally identical to the existing
four joins (composite key `{ChannelId, GraphicsElementId}`), added via a dual-provider migration
(`scripts/add-migration.sh Add_ChannelGraphicsElement`).
- `GraphicsElementSelector.SelectGraphicsElements` appends channel-level elements at the **final
fall-through**, alongside `playoutItem.PlayoutItemGraphicsElements` — a **base layer**. A deco in
`Merge` mode composes with it; a deco in `Override`/`Disable` mode returns earlier and so
suppresses it (decos are allowed to override channel defaults, a deliberate rule). One more
suppression path: on a **filler** item, a deco whose graphics-elements section is not set to run
during filler (`UseGraphicsElementsDuringFiller` false) clears the result and returns for `Merge`
and `Override` alike, so the channel base layer is dropped there too.
`HttpLiveStreamingDirect` continues to return empty (ErsatzTV isn't transcoding, so there is no
frame pipeline to draw into).
- **Seeded built-in element**, mirroring the `iptv.logo-drives-bug-preset` (#67) pattern:
`GraphicsElementSeeder.SeedOnNowNext` writes `on-now-next.yml` into the graphics-elements
templates folder (only if the file is absent — operator edits are never clobbered) and ensures a
`GraphicsElement` row exists for it, guarded by the `graphics.on_now_next_seeded` `ConfigElement`
marker so it runs once per database, not once per file-absence (the same reasoning as #67: the
seeder runs at every startup, so a name/file-only guard would resurrect a deliberately deleted
preset).
- The API needed a way for the SPA to find the built-in element without a fragile name-match — the
direct #67 lesson (`WatermarkResponseModel.imageSource`). `GraphicsElementResponseModel` gained a
server-derived `BuiltIn` bool, computed by comparing the row's `Path` filename to
`GraphicsElementDefaults.OnNowNextFileName` rather than trusting the element's editable `Name`.
- The channel editor's Branding-tab "Show On Now / Next overlay" switch follows the exact pattern
of the existing logo-bug toggle: on adds the built-in element's id to `graphicsElementIds`, off
removes it; disabled (with an explanatory caption) when the channel is HLS-Direct.
**Accepted trade-off:** all channels that enable the toggle share one seeded element's geometry/
content; per-channel customization means editing the shared YAML or attaching a different element
(the join is general, not restricted to the seeded one).
## 2026-07-20 (#498) — QSV decode is split from QSV encode via a single `QsvPreferNativeDecoder` bool
`key: ffmpeg.qsv-decode-encode-split` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum.
+1
View File
@@ -67,6 +67,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `ffmpeg.remote-image-fetcher-bounded` | remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init. | 2026-07-20 | [link](../decisions.md#2026-07-20--remote-graphics-engine-images-are-fetched-through-a-bounded-pooled-iremoteimagefetcher-re-fetched-per-element-init-not-cached-511) |
| `ffmpeg.work-ahead-slot-atomic` | `workAheadSegmenterLimit` is enforced by a single compare-exchange claim on a shared `WorkAheadSlots` pool taken by the *caller* of `Transcode`, which then passes ownership in and gets the release in `Transcode`'s `finally` — never a `Volatile.Read` compare in one place and an `Interlocked.Increment` in another. | 2026-07-21 | [link](../decisions.md#2026-07-21--work-ahead-slots-are-claimed-atomically-by-the-caller-released-by-the-transcode-it-hands-them-to-536) |
| `ffmpeg.work-ahead-slot-release-never-negative` | `Release()` reads the count and compare-exchanges `current - 1` only when `current > 0`; a release against an empty pool records an unbalanced release and returns `false` **without ever writing a negative value**. It never decrements first and clamps afterward. The single caller (`HlsSessionWorker.Transcode`'s `finally`) logs a warning on the `false` return. | 2026-07-21 | [link](../decisions.md#2026-07-21--workaheadslotsrelease-clamps-before-decrementing-and-reports-unbalance-in-band-539) |
| `graphics.channel-level-attachment` | A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box. | 2026-07-22 | [link](../decisions.md#2026-07-22--channel-level-graphics-element-attachment--seeded-on-nownext-text-element-74) |
| `graphics.channel-logo-caching` | An external `http(s)` channel-logo URL is fetched, decode-budget-validated, and stored in the image cache under a content-hash name at SAVE time — becoming byte-identical to an uploaded logo — so the render path never fetches a logo over HTTP; a bad URL fails the save with a 422 (BaseError → ValidationProblemDetails). | 2026-07-21 | [link](../decisions.md#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525) |
| `iptv.base-url` | An optional advertised base URL (`iptv.base_url`) is resolved centrally via a pure Core helper (`AdvertisedBaseUrl`) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new `iptv` settings group distinct from `ETV_BASE_URL` and out of scope for HDHomeRun. | 2026-07-16 | [link](../decisions.md#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340) |
| `iptv.logo-drives-bug-preset` | One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema. | 2026-07-20 | [link](../decisions.md#2026-07-20--one-logo-drives-the-bug-via-a-shared-channellogo-preset-not-new-schema-67) |
+1
View File
@@ -68,6 +68,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
| **Seasonal / date-conditional scheduling** (#73) | Holiday/seasonal channels are **not a separate feature** — they are the existing date predicate on `IAlternateScheduleItem`, implemented by `ProgramScheduleAlternate` (Classic) and `PlayoutTemplate` (Block), evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match by `Index`, catch-all last). **Leaving `StartYear`/`EndYear` empty makes the range repeat every year** — the "set once, works every December" switch; explicit years (required in pairs) mean a one-off window and disable wrap-around detection. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31) are handled. No *soft* prioritization primitive exists (binary first-match-wins); that ask belongs to #70's weighting work. See `channels.md` → "Recipe: seasonal / holiday programming" and `decisions.md` 2026-07-17. | `IAlternateScheduleItem`, `ProgramScheduleAlternate`, `PlayoutTemplate` | `/app/playouts/{id}/alternate-schedules`, `/app/playouts/{id}/templates` |
| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is FisherYates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `WeightedShuffle` is offered as a Playback Order **only** on classic schedule items whose source is a MultiCollection (`web/src/schedules/itemRules.ts`, #404); the per-source weights themselves are edited at `/app/multi-collections` |
| **Watermark** | `ChannelWatermark` image overlay; attached at channel, schedule-item, block-item, deco, or playout-item level with position/size/opacity. | `ChannelWatermark`, `DecoWatermark`, `BlockItemWatermark`, `ProgramScheduleItemWatermark` | `/app/watermarks` |
| **Graphics element** | YAML-authored (`Text`/`Image`/`Subtitle`/`Motion`/`Script`) render-engine overlay, distinct from the image-only `ChannelWatermark` system. Attaches via 5 parallel join tables: `PlayoutItemGraphicsElement`, `ProgramScheduleItemGraphicsElement`, `BlockItemGraphicsElement`, `DecoGraphicsElement`, and (#74) **`ChannelGraphicsElement`** — a direct `Channel`-level attachment that did not exist before #74. `GraphicsElementSelector.SelectGraphicsElements` treats channel-level elements as the final fall-through **base layer**: they merge with `Merge`-mode deco elements and per-playout-item elements, but a deco in `Override`/`Disable` mode returns before that fall-through and so **suppresses** the channel overlay (and on a **filler** item, a deco whose graphics-elements section is not set to run during filler — `UseGraphicsElementsDuringFiller` false — clears it too, for `Merge` and `Override` alike); `HttpLiveStreamingDirect` always returns empty (ErsatzTV isn't transcoding, so nothing can be burned in). A built-in seeded text element, `on-now-next.yml` (`GraphicsElementDefaults.OnNowNextFileName`), is written once (`GraphicsElementSeeder.SeedOnNowNext`, guarded by the `graphics.on_now_next_seeded` ConfigElement marker, adopt-not-clobber like the #67 watermark seed) and identified to API clients via a server-derived `GraphicsElementResponseModel.builtIn` flag (path-name comparison, not name matching). Edited per-channel at Channel editor → Branding → "Show On Now / Next overlay". See `decisions.md``graphics.channel-level-attachment` (#74). | `GraphicsElement`, `ChannelGraphicsElement` | `/app/edit-channel/{id}` (Branding tab); YAML files under `GraphicsElementsTextTemplatesFolder` etc. are not directly SPA-edited |
| **Collection** | Manual list of media items (`CollectionItem`). | `Collection` | `/app/collections` |
| **SmartCollection** | Saved search — a `Query` string, no static item list. | `SmartCollection` | `/app/collections` |
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. The editor exposes a per-source weight input (1..1000, mirroring the API validator) with a computed % share and a "Reset to fair share" action; it round-trips `weight` from the GET because the PUT replaces the item list (#404). | `MultiCollection` | `/app/multi-collections` (#151, weight UI #404) |
@@ -0,0 +1,899 @@
# On Now / Next Overlay (#74) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a per-channel toggle that burns a transient "On Now / Next" EPG text bug onto the transcoded stream at each program transition, reusing the existing SkiaSharp graphics engine.
**Architecture:** The graphics engine already renders dynamic EPG text per playout item. We add (1) a `ChannelGraphicsElement` join so a channel can carry graphics elements (the missing channel-level hook), (2) a seeded built-in `on-now-next.yml` text element, (3) a render-path change so the selector emits channel-level elements, (4) a channel REST field + a `builtIn` discriminator on graphics elements, and (5) a SPA Branding-tab toggle mirroring the existing logo-bug switch.
**Tech Stack:** C# / .NET 10, EF Core (dual-provider Sqlite + MySql), MediatR CQRS, LanguageExt, SkiaSharp + RichTextKit graphics engine, Scriban + NCalc templating, React + TypeScript SPA, NUnit + Shouldly + NSubstitute, generated OpenAPI (`v1.json` / `v1.d.ts`).
## Global Constraints
- Work only in the worktree `.claude/worktrees/issue-74-on-now-next-overlay` (branch `feat/74-on-now-next-overlay`, off `origin/main`). Never commit in `/Users/timothy/ersatztv`.
- Any `TvContext` model change requires a dual-provider migration via `scripts/add-migration.sh <Name>` (generates BOTH Sqlite and MySql). Never hand-edit migrations. Never set `ETV_UPDATE_GOLDENS`.
- Tests: NUnit + Shouldly + NSubstitute only (never xUnit). C# test DB harness is `InMemoryTvContext` (real SQLite `:memory:`, FKs OFF), constructed `_db = await InMemoryTvContext.CreateAsync();` then `await using TvContext context = _db.CreateContext();`.
- Any `/api/*` change: build the app project FIRST, then `./scripts/update-openapi.sh`, then `cd web && npm run generate:api`. Commit regenerated `ErsatzTV/wwwroot/api/v1.json`, `web/src/api/generated/v1.d.ts`, and `docs/endpoint-index.md` in the same task.
- Before any push touching `.cs`: BOM-check the touched set (`git diff --cached --name-only -- '*.cs' | while read f; do xxd -p -l3 "$f" | grep -q '^efbbbf' && echo "BOM: $f"; done`) and run the format gate under `bash -c`: `bash -c 'dotnet format whitespace ErsatzTV.sln --folder --include $(git diff --name-only origin/main -- "*.cs" | tr "\n" " ")'`.
- Central Package Management: never add `Version=` to a `<PackageReference>`.
- Text-element inline style markup uses SQUARE brackets with a backreferenced close: `[styleName]text[/styleName]`. `base_style` MUST name a style present in `styles:` or the renderer throws.
- EPG template variable is `Epg` (a list); members are PascalCase: `Epg[0].Title`, `Epg[0].SubTitle`, `Epg[1].Title`, `Epg[1].Start` (`DateTimeOffset`). Now = `Epg[0]`, Next = `Epg[1]`.
- `opacity_expression` is NCalc; time params (seconds, doubles): `content_seconds`, `content_total_seconds`, `channel_seconds`, `time_of_day_seconds`. Helper functions: `LinearFadeDuration(time, start, fadeSeconds, peakSeconds)` and `LinearFadePoints(time, start, peakStart, peakEnd, end)`. When `opacity_expression` is set, `opacity_percent` is ignored.
- Built-in element identity is by filename constant `on-now-next.yml` (see Task 3 `GraphicsElementDefaults`), never by user-editable Name (the #67 lesson).
---
### Task 1: `ChannelGraphicsElement` join entity + EF config + dual-provider migration
**Files:**
- Create: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`
- Modify: `ErsatzTV.Core/Domain/Channel.cs` (add navs)
- Modify: `ErsatzTV.Core/Domain/GraphicsElement.cs` (add navs)
- Modify: `ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs` (add M2M)
- Create (generated): `ErsatzTV.Infrastructure.Sqlite/Migrations/*_Add_ChannelGraphicsElement.cs` + `ErsatzTV.Infrastructure.MySql/Migrations/*_Add_ChannelGraphicsElement.cs`
- Test: `ErsatzTV.Tests/Infrastructure/ChannelGraphicsElementPersistenceTests.cs`
**Interfaces:**
- Produces: `ChannelGraphicsElement { int ChannelId; Channel Channel; int GraphicsElementId; GraphicsElement GraphicsElement }`; `Channel.ChannelGraphicsElements : List<ChannelGraphicsElement>`; `Channel.GraphicsElements : List<GraphicsElement>`; `GraphicsElement.ChannelGraphicsElements : List<ChannelGraphicsElement>`; `GraphicsElement.Channels : List<Channel>`.
- [ ] **Step 1: Write the failing persistence test**
Create `ErsatzTV.Tests/Infrastructure/ChannelGraphicsElementPersistenceTests.cs`:
```csharp
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Infrastructure;
[TestFixture]
public class ChannelGraphicsElementPersistenceTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Channel_Can_Attach_And_Read_Back_A_Graphics_Element()
{
int channelId;
int elementId;
await using (TvContext context = _db.CreateContext())
{
var profile = new FFmpegProfile { Name = "p" };
await context.FFmpegProfiles.AddAsync(profile);
var element = new GraphicsElement { Path = "/x/on-now-next.yml", Name = "On Now / Next", Kind = GraphicsElementKind.Text };
await context.GraphicsElements.AddAsync(element);
await context.SaveChangesAsync();
var channel = new Channel(Guid.NewGuid())
{
Number = "1", Name = "c", Group = "g", FFmpegProfileId = profile.Id,
ChannelGraphicsElements = [new ChannelGraphicsElement { GraphicsElementId = element.Id }]
};
await context.Channels.AddAsync(channel);
await context.SaveChangesAsync();
channelId = channel.Id;
elementId = element.Id;
}
await using (TvContext context = _db.CreateContext())
{
Channel loaded = await context.Channels
.Include(c => c.ChannelGraphicsElements)
.ThenInclude(x => x.GraphicsElement)
.SingleAsync(c => c.Id == channelId);
loaded.ChannelGraphicsElements.Count.ShouldBe(1);
loaded.ChannelGraphicsElements[0].GraphicsElementId.ShouldBe(elementId);
loaded.ChannelGraphicsElements[0].GraphicsElement.Name.ShouldBe("On Now / Next");
}
}
}
```
- [ ] **Step 2: Run it to verify it fails to compile**
Run: `dotnet build ErsatzTV.Tests`
Expected: FAIL — `ChannelGraphicsElement` / `Channel.ChannelGraphicsElements` do not exist.
- [ ] **Step 3: Create the join entity**
`ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`:
```csharp
namespace ErsatzTV.Core.Domain;
public class ChannelGraphicsElement
{
public int ChannelId { get; set; }
public Channel Channel { get; set; }
public int GraphicsElementId { get; set; }
public GraphicsElement GraphicsElement { get; set; }
}
```
- [ ] **Step 4: Add navs to `Channel` and `GraphicsElement`**
In `ErsatzTV.Core/Domain/Channel.cs`, alongside `public List<Artwork> Artwork { get; set; }`:
```csharp
public List<GraphicsElement> GraphicsElements { get; set; }
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
```
In `ErsatzTV.Core/Domain/GraphicsElement.cs`, alongside the `Decos` / `DecoGraphicsElements` navs:
```csharp
public List<Channel> Channels { get; set; }
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
```
- [ ] **Step 5: Declare the M2M in `ChannelConfiguration`**
In `ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs`, inside `Configure`, after the `MirrorSourceChannel` block, add (mirrors `DecoConfiguration`'s `.UsingEntity<DecoGraphicsElement>`):
```csharp
builder.HasMany(c => c.GraphicsElements)
.WithMany(m => m.Channels)
.UsingEntity<ChannelGraphicsElement>(
j => j.HasOne(ci => ci.GraphicsElement)
.WithMany(mi => mi.ChannelGraphicsElements)
.HasForeignKey(ci => ci.GraphicsElementId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasOne(ci => ci.Channel)
.WithMany(c => c.ChannelGraphicsElements)
.HasForeignKey(ci => ci.ChannelId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(ci => new { ci.ChannelId, ci.GraphicsElementId }));
```
Note: `ChannelConfiguration.cs` starts with a UTF-8 BOM already; preserve it (do not strip), and BOM-check per Global Constraints before pushing.
- [ ] **Step 6: Generate the dual-provider migration**
Run: `scripts/add-migration.sh Add_ChannelGraphicsElement`
Expected: two new migration files (Sqlite + MySql) creating a `ChannelGraphicsElement` table with composite PK `(ChannelId, GraphicsElementId)` and two cascade FKs. Inspect both to confirm the table + FKs; no other model drift.
- [ ] **Step 7: Run the test to verify it passes**
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelGraphicsElementPersistenceTests`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add ErsatzTV.Core/Domain/ChannelGraphicsElement.cs ErsatzTV.Core/Domain/Channel.cs ErsatzTV.Core/Domain/GraphicsElement.cs \
ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs \
ErsatzTV.Infrastructure.Sqlite/Migrations ErsatzTV.Infrastructure.MySql/Migrations \
ErsatzTV.Tests/Infrastructure/ChannelGraphicsElementPersistenceTests.cs
git -c core.hooksPath=/dev/null commit -m "feat(74): add ChannelGraphicsElement join + dual-provider migration"
```
---
### Task 2: Render-path — selector emits channel-level graphics elements
**Files:**
- Modify: `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs` (append channel elements at the fall-through)
- Modify: `ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs` (eager-load the join on the streaming channel)
- Test: `ErsatzTV.Tests/Core/FFmpeg/GraphicsElementSelectorTests.cs`
**Interfaces:**
- Consumes: `Channel.ChannelGraphicsElements` (Task 1); `IGraphicsElementSelector.SelectGraphicsElements(Channel, PlayoutItem, DateTimeOffset)`.
- Produces: channel-level `PlayoutItemGraphicsElement`s appended as a base layer (below deco/playout precedence).
- [ ] **Step 1: Write the failing selector tests**
Create `ErsatzTV.Tests/Core/FFmpeg/GraphicsElementSelectorTests.cs`. `DecoEntries` is `record(Option<Deco> TemplateDeco, Option<Deco> PlayoutDeco)`; the selector iterates each Option. Use `NSubstitute` for `IDecoSelector` and `NullLogger`.
```csharp
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Core.FFmpeg;
[TestFixture]
public class GraphicsElementSelectorTests
{
private static (GraphicsElementSelector sel, IDecoSelector deco) Build(DecoEntries entries)
{
var deco = Substitute.For<IDecoSelector>();
deco.GetDecoEntries(Arg.Any<Playout>(), Arg.Any<DateTimeOffset>()).Returns(entries);
return (new GraphicsElementSelector(deco, NullLogger<GraphicsElementSelector>.Instance), deco);
}
private static Channel ChannelWithElement(int elementId, StreamingMode mode = StreamingMode.HttpLiveStreaming)
{
var element = new GraphicsElement { Id = elementId, Path = "/x/on-now-next.yml", Kind = GraphicsElementKind.Text };
return new Channel(Guid.NewGuid())
{
StreamingMode = mode,
ChannelGraphicsElements = [new ChannelGraphicsElement { GraphicsElementId = elementId, GraphicsElement = element }]
};
}
private static PlayoutItem Item() => new()
{
Playout = new Playout(),
FillerKind = FillerKind.None,
PlayoutItemGraphicsElements = []
};
[Test]
public void Channel_Element_Is_Emitted_When_No_Deco()
{
(GraphicsElementSelector sel, _) = Build(new DecoEntries(Option<Deco>.None, Option<Deco>.None));
List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(ChannelWithElement(7), Item(), DateTimeOffset.Now);
result.Count.ShouldBe(1);
result[0].GraphicsElement.Id.ShouldBe(7);
}
[Test]
public void Channel_Element_Is_Suppressed_On_HlsDirect()
{
(GraphicsElementSelector sel, _) = Build(new DecoEntries(Option<Deco>.None, Option<Deco>.None));
List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(
ChannelWithElement(7, StreamingMode.HttpLiveStreamingDirect), Item(), DateTimeOffset.Now);
result.ShouldBeEmpty();
}
[Test]
public void Channel_Element_Is_Suppressed_By_Disable_Deco()
{
var deco = new Deco { GraphicsElementsMode = DecoMode.Disable, DecoGraphicsElements = [] };
(GraphicsElementSelector sel, _) = Build(new DecoEntries(deco, Option<Deco>.None));
List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(ChannelWithElement(7), Item(), DateTimeOffset.Now);
result.ShouldBeEmpty();
}
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~GraphicsElementSelectorTests`
Expected: FAIL — `Channel_Element_Is_Emitted_When_No_Deco` returns 0 (selector never reads `channel`).
- [ ] **Step 3: Append channel elements in the selector**
In `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, replace the final two lines:
```csharp
result.AddRange(playoutItem.PlayoutItemGraphicsElements);
return result;
```
with:
```csharp
result.AddRange(playoutItem.PlayoutItemGraphicsElements);
// channel-level overlays are a base layer: merged with playout-item / Merge-deco elements,
// but suppressed by a deco in Override/Disable mode (which returns before reaching here).
if (channel.ChannelGraphicsElements is not null)
{
result.AddRange(
channel.ChannelGraphicsElements.Map(cge =>
new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = cge.GraphicsElement }));
}
return result;
```
- [ ] **Step 4: Run to verify it passes**
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~GraphicsElementSelectorTests`
Expected: PASS (all three).
- [ ] **Step 5: Eager-load the join on the streaming channel path**
In `ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs`, in `ChannelMustExist`, add after `.Include(c => c.Watermark)`:
```csharp
.Include(c => c.ChannelGraphicsElements)
.ThenInclude(x => x.GraphicsElement)
```
(No other Channel-load site needs it: `GetHlsPlaylistByChannelNumberHandler` doesn't build the graphics graph, and troubleshooting playback re-enters through `FFmpegProcessHandler`.)
- [ ] **Step 6: Build to confirm the include compiles**
Run: `dotnet build ErsatzTV.Application`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs \
ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs \
ErsatzTV.Tests/Core/FFmpeg/GraphicsElementSelectorTests.cs
git -c core.hooksPath=/dev/null commit -m "feat(74): selector emits channel-level graphics elements as a base layer"
```
---
### Task 3: Seed the built-in `on-now-next.yml` template (file + marker)
**Files:**
- Create: `ErsatzTV.Core/Graphics/GraphicsElementDefaults.cs` (shared filename const)
- Modify: `ErsatzTV.Core/Domain/ConfigElementKey.cs` (new marker key)
- Create: `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs` (the YAML const + seed logic, `IFileSystem`-based)
- Modify: `ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs` (invoke the seeder before `DatabaseIsReady`)
- Test: `ErsatzTV.Tests/Infrastructure/GraphicsElementSeederTests.cs`
**Interfaces:**
- Produces: `GraphicsElementDefaults.OnNowNextFileName = "on-now-next.yml"`; `ConfigElementKey.GraphicsOnNowNextSeeded` (`"graphics.on_now_next_seeded"`); `GraphicsElementSeeder.SeedOnNowNext(TvContext, IFileSystem, CancellationToken)`.
- Row creation is intentionally NOT done here — the existing `RefreshGraphicsElementsHandler` (already tested, single owner of `GraphicsElement` rows) creates the row from the on-disk file at startup. The seeder only materializes the file + marker (marker-gated, adopt-not-clobber, no resurrection after delete), following #67.
- [ ] **Step 1: Write the failing seeder tests**
Create `ErsatzTV.Tests/Infrastructure/GraphicsElementSeederTests.cs`. Uses the repo's `Testably.Abstractions.Testing.MockFileSystem` (implements `System.IO.Abstractions.IFileSystem` — standard `fs.File.*` / `fs.Directory.*` surface; NOT the `AddFile`/`MockFileData`/`FileExists` API of `System.IO.Abstractions.TestingHelpers`) and `InMemoryTvContext`. The seeded path is `FileSystemLayout.GraphicsElementsTextTemplatesFolder + "/on-now-next.yml"`.
```csharp
using System.IO.Abstractions;
using Testably.Abstractions.Testing;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Infrastructure;
[TestFixture]
public class GraphicsElementSeederTests
{
private InMemoryTvContext _db = null!;
private string _seededPath = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_seededPath = Path.Combine(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "on-now-next.yml");
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Seeds_File_And_Marker_When_Absent()
{
var fs = new MockFileSystem();
await using TvContext context = _db.CreateContext();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
fs.File.Exists(_seededPath).ShouldBeTrue();
fs.File.ReadAllText(_seededPath).ShouldContain("epg_entries: 2");
(await context.ConfigElements.AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextSeeded.Key)).ShouldBeTrue();
}
[Test]
public async Task Adopts_Existing_File_Untouched()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
await fs.File.WriteAllTextAsync(_seededPath, "name: Operator Custom\nepg_entries: 2\n");
await using TvContext context = _db.CreateContext();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
fs.File.ReadAllText(_seededPath).ShouldContain("Operator Custom");
}
[Test]
public async Task Does_Not_Resurrect_After_Delete()
{
var fs = new MockFileSystem();
await using TvContext context = _db.CreateContext();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
fs.File.Delete(_seededPath);
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
fs.File.Exists(_seededPath).ShouldBeFalse();
}
}
```
- [ ] **Step 2: Run to verify it fails to compile**
Run: `dotnet build ErsatzTV.Tests`
Expected: FAIL — `GraphicsElementSeeder` / `ConfigElementKey.GraphicsOnNowNextSeeded` do not exist.
- [ ] **Step 3: Add the shared filename const**
`ErsatzTV.Core/Graphics/GraphicsElementDefaults.cs`:
```csharp
namespace ErsatzTV.Core.Graphics;
public static class GraphicsElementDefaults
{
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
public const string OnNowNextFileName = "on-now-next.yml";
}
```
- [ ] **Step 4: Add the ConfigElement marker key**
In `ErsatzTV.Core/Domain/ConfigElementKey.cs`, next to `WatermarkChannelBugSeeded`:
```csharp
public static ConfigElementKey GraphicsOnNowNextSeeded => new("graphics.on_now_next_seeded");
```
- [ ] **Step 5: Create the seeder (YAML const + logic)**
`ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`. The YAML is authored from the field grammar (Global Constraints): square-bracket style spans, `Epg[0]`/`Epg[1]`, `LinearFadeDuration`, and a Scriban guard so a missing NEXT entry renders nothing. `base_style: now` names a real style.
```csharp
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public static class GraphicsElementSeeder
{
private const string OnNowNextYaml =
"""
name: On Now / Next
epg_entries: 2
location: BottomLeft
horizontal_margin_percent: 4
vertical_margin_percent: 8
width_percent: 42
text_fit: Wrap
text_align: Left
z_index: 100
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
base_style: now
styles:
- name: now
font_size: 30
font_weight: 700
text_color: "#FFFFFF"
halo_color: "#000000"
halo_width: 2
- name: sub
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 2
- name: next
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 2
text: |
[now]NOW {{ Epg[0].Title }}[/now]
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
{{ if Epg.size > 1 }}[next]NEXT {{ Epg[1].Title }} · {{ format_datetime (convert_timezone Epg[1].Start) "h:mm tt" }}[/next]{{ end }}
""";
public static async Task SeedOnNowNext(TvContext context, IFileSystem fileSystem, CancellationToken cancellationToken)
{
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
if (alreadySeeded)
{
return;
}
string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);
if (!fileSystem.Directory.Exists(folder))
{
fileSystem.Directory.CreateDirectory(folder);
}
// Adopt an operator's existing file untouched; only write when absent.
if (!fileSystem.File.Exists(target))
{
await fileSystem.File.WriteAllTextAsync(target, OnNowNextYaml, cancellationToken);
}
await context.ConfigElements.AddAsync(
new ConfigElement { Key = seededKey, Value = "true" },
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
}
```
- [ ] **Step 6: Run the seeder tests to verify they pass**
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~GraphicsElementSeederTests`
Expected: PASS (all three).
- [ ] **Step 7: Invoke the seeder at startup**
In `ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs`, resolve `IFileSystem` from the scope and call the seeder between `DbInitializer.Initialize` and `DatabaseIsReady`:
```csharp
_logger.LogInformation("Initializing database");
await DbInitializer.Initialize(dbContext, stoppingToken);
var fileSystem = scope.ServiceProvider.GetRequiredService<System.IO.Abstractions.IFileSystem>();
await GraphicsElementSeeder.SeedOnNowNext(dbContext, fileSystem, stoppingToken);
_systemStartup.DatabaseIsReady();
```
Add `using ErsatzTV.Infrastructure.Streaming.Graphics;` at the top. (`IFileSystem` is already DI-registered — `RefreshGraphicsElementsHandler` consumes it. The seeder runs before `DatabaseIsReady`, hence before `SchedulerService` queues `RefreshGraphicsElements`, so the file is present when the refresh creates the row.)
- [ ] **Step 8: Build to confirm startup wiring compiles**
Run: `dotnet build ErsatzTV`
Expected: PASS.
- [ ] **Step 9: Commit**
```bash
git add ErsatzTV.Core/Graphics/GraphicsElementDefaults.cs ErsatzTV.Core/Domain/ConfigElementKey.cs \
ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs \
ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs \
ErsatzTV.Tests/Infrastructure/GraphicsElementSeederTests.cs
git -c core.hooksPath=/dev/null commit -m "feat(74): seed built-in on-now-next.yml text element (file + marker)"
```
---
### Task 4: API — channel `graphicsElementIds` + graphics `builtIn` + OpenAPI regen
**Files:**
- Modify: `ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs` (add `int[] GraphicsElementIds`)
- Modify: `ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs` (add `List<int> GraphicsElementIds` + thread through `ToCommand`)
- Modify: `ErsatzTV.Application/Channels/Commands/UpdateChannel.cs` (add `List<int> GraphicsElementIds`)
- Modify: `ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs` (include + reconcile)
- Modify: `ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs` (include on `GetChannel`)
- Modify: `ErsatzTV.Application/Channels/Mapper.cs` (project ids)
- Modify: `ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs` (add `bool BuiltIn`)
- Modify: `ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs` (derive `builtIn`)
- Generated: `ErsatzTV/wwwroot/api/v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md`
- Test: `ErsatzTV.Tests/Application/Channels/UpdateChannelGraphicsElementsTests.cs`
**Interfaces:**
- Consumes: `Channel.ChannelGraphicsElements` (Task 1); `GraphicsElementDefaults.OnNowNextFileName` (Task 3).
- Produces: DTO field `GraphicsElementIds` on channel detail + update; `BuiltIn` on `GraphicsElementResponseModel`.
- [ ] **Step 1: Write the failing reconcile test**
Create `ErsatzTV.Tests/Application/Channels/UpdateChannelGraphicsElementsTests.cs`. Drive `UpdateChannelHandler.Handle` against `InMemoryTvContext` with a channel + two graphics elements; assert the join set is reconciled (add then remove). Construct the handler with the same collaborators the existing `UpdateChannelHandler` tests use — read `ErsatzTV.Tests/Application/Channels/` for the existing setup (an `IDbContextFactory<TvContext>` over `_db`, `ISearchTargets` substitute, channel-data refresh substitutes). Assertion core:
```csharp
// after sending UpdateChannel with GraphicsElementIds = [elementA.Id]
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channelId);
reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe(new[] { elementAId });
// after sending UpdateChannel with GraphicsElementIds = [] (remove)
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
```
If no `UpdateChannelHandler` test exists to copy the harness from, construct the factory as `new PooledDbContextFactory<TvContext>(_db.Options)`-equivalent used elsewhere in `ErsatzTV.Tests/Application/` (grep for `IDbContextFactory<TvContext>` test usages) and substitute the other ctor deps with `Substitute.For<...>()`.
- [ ] **Step 2: Run to verify it fails**
Run: `dotnet build ErsatzTV.Tests`
Expected: FAIL — `UpdateChannel.GraphicsElementIds` does not exist.
- [ ] **Step 3: Add `GraphicsElementIds` to the command + request DTO**
In `ErsatzTV.Application/Channels/Commands/UpdateChannel.cs`, add a final positional parameter:
```csharp
bool ShowInEpg,
List<int> GraphicsElementIds) : IRequest<Either<BaseError, ChannelViewModel>>;
```
In `ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs`, add the matching final record parameter `List<int> GraphicsElementIds` and pass it as the final `ToCommand` argument (`GraphicsElementIds ?? []`).
- [ ] **Step 4: Reconcile the join in `UpdateChannelHandler`**
In `ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs`, add to the channel load in `Handle`:
```csharp
.Include(c => c.ChannelGraphicsElements)
```
In `ApplyUpdateRequest`, immediately before `await dbContext.SaveChangesAsync(cancellationToken);`, reconcile (mirrors the artwork add/remove block):
```csharp
c.ChannelGraphicsElements ??= [];
var desired = update.GraphicsElementIds?.Distinct().ToList() ?? [];
c.ChannelGraphicsElements.RemoveAll(cge => !desired.Contains(cge.GraphicsElementId));
foreach (int id in desired.Where(id => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != id)))
{
c.ChannelGraphicsElements.Add(new ChannelGraphicsElement { ChannelId = c.Id, GraphicsElementId = id });
}
```
- [ ] **Step 5: Include the join on the detail read + project ids**
In `ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs` `GetChannel`, add before `.OrderBy(c => c.Id)`:
```csharp
.Include(c => c.ChannelGraphicsElements)
.ThenInclude(x => x.GraphicsElement)
```
In `ErsatzTV.Application/Channels/Mapper.cs` `ProjectToDetailResponseModel`, add the final constructor argument:
```csharp
channel.ShowInEpg,
channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? []);
```
In `ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs`, add the final record parameter:
```csharp
bool ShowInEpg,
int[] GraphicsElementIds);
```
- [ ] **Step 6: Add `BuiltIn` to the graphics-element DTO + handler**
In `ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs`:
```csharp
public record GraphicsElementResponseModel(int Id, string Name, bool BuiltIn);
```
In `ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs`, derive `builtIn` from the entity's filename (keep the existing VM ordering; carry the entity alongside the VM):
```csharp
return graphicsElements
.Select(e => new
{
Vm = ProjectToViewModel(e),
BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
})
.OrderBy(x => x.Vm.Name == x.Vm.FileName)
.ThenBy(x => x.Vm.Name)
.Select(x => new GraphicsElementResponseModel(x.Vm.Id, x.Vm.Name, x.BuiltIn))
.ToList();
```
Add `using ErsatzTV.Core.Graphics;`.
- [ ] **Step 7: Run the reconcile test to verify it passes**
Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~UpdateChannelGraphicsElementsTests`
Expected: PASS.
- [ ] **Step 8: Regenerate OpenAPI artifacts**
Run:
```bash
dotnet build ErsatzTV
./scripts/update-openapi.sh
cd web && npm run generate:api && cd ..
```
Expected: `v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md` now show `graphicsElementIds` on the channel models and `builtIn` on `GraphicsElementResponseModel`. Confirm `git diff --stat` lists exactly those generated files.
- [ ] **Step 9: Update `docs/api-conventions.md` checklist note**
Add a one-line entry noting the channel now carries `graphicsElementIds` (attached graphics elements) and graphics elements expose `builtIn`. Keep it consistent with the existing checklist style.
- [ ] **Step 10: Commit**
```bash
git add ErsatzTV.Core/Api ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs \
ErsatzTV.Application/Channels ErsatzTV.Application/Graphics \
ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs \
ErsatzTV/wwwroot/api/v1.json web/src/api/generated/v1.d.ts docs/endpoint-index.md docs/api-conventions.md \
ErsatzTV.Tests/Application/Channels/UpdateChannelGraphicsElementsTests.cs
git -c core.hooksPath=/dev/null commit -m "feat(74): channel graphicsElementIds + graphics builtIn; regen OpenAPI"
```
---
### Task 5: SPA — Branding-tab "Show On Now / Next overlay" toggle
**Files:**
- Create: `web/src/api/graphicsElements.ts` (`findBuiltInOnNowNext` helper)
- Modify: `web/src/screens/ChannelEditScreen.tsx` (load elements into ReferenceData; draft field; the Switch)
- Test: `web/src/screens/ChannelEditScreen.test.tsx` (or a focused component test if the harness has one; else document manual verification)
**Interfaces:**
- Consumes: generated types now carry `graphicsElementIds` and `GraphicsElementResponseModel.builtIn` (Task 4); `getGraphicsElements()` in `web/src/api/pickers.ts`.
- [ ] **Step 1: Add the built-in finder helper**
`web/src/api/graphicsElements.ts` (mirrors `findLogoBugWatermark`):
```ts
import type { components } from './generated/v1';
export type GraphicsElement = components['schemas']['GraphicsElementResponseModel'];
// Prefer the server-declared built-in flag; never match on the user-editable name.
export function findBuiltInOnNowNext(elements: GraphicsElement[]): GraphicsElement | null {
return elements.find((e) => e.builtIn) ?? null;
}
```
- [ ] **Step 2: Load graphics elements into `ReferenceData`**
In `web/src/screens/ChannelEditScreen.tsx`: add `getGraphicsElements` (from `../api/pickers`) to the reference-data `Promise.all`, add `graphicsElements` to the `ReferenceData` type, and pass it into `BrandingPane` via the existing `data` prop.
- [ ] **Step 3: Thread the draft field**
In `draftFromChannel`, add `graphicsElementIds: channel.graphicsElementIds ?? []`. (The generated `UpdateChannelRequest`/`Channel` types now include the field.) `dirty` tracking (JSON.stringify) picks it up automatically.
- [ ] **Step 4: Add the toggle in `BrandingPane`**
After the logo-bug `Row`, add (uses `findBuiltInOnNowNext`; no live preview, static caption):
```tsx
{(() => {
const onNowNext = findBuiltInOnNowNext(data.graphicsElements);
const ids = draft.graphicsElementIds ?? [];
const enabled = onNowNext != null && ids.includes(onNowNext.id);
return (
<Row
control={340}
help={
onNowNext == null
? 'The built-in On Now / Next element is not available yet.'
: 'Briefly shows the current and next program at each program change. Not shown in HLS Direct mode.'
}
label="Show On Now / Next overlay"
>
<Switch
checked={enabled}
disabled={hlsDirect || onNowNext == null}
hideLabel
label="Show On Now / Next overlay"
onChange={(next) => {
if (onNowNext == null) return;
const without = ids.filter((id) => id !== onNowNext.id);
set({ graphicsElementIds: next ? [...without, onNowNext.id] : without });
}}
size="sm"
/>
</Row>
);
})()}
```
- [ ] **Step 5: Typecheck + build the SPA**
Run: `cd web && npm run build`
Expected: PASS (no TS errors; `graphicsElementIds` and `builtIn` resolve against the regenerated types).
- [ ] **Step 6: Web test (if harness present)**
If `web/src/screens/ChannelEditScreen.test.tsx` exists, add a test that renders the Branding pane with a built-in element in `data.graphicsElements` and asserts toggling the switch adds/removes the id from the draft. Give heavy-render tests an explicit per-test timeout (e.g. `{ timeout: 15000 }`). Run: `cd web && npm run test -- ChannelEditScreen`. If no component-test harness exists for this screen, note that verification is covered by the live-E2E in Task 6 and skip.
- [ ] **Step 7: Commit**
```bash
git add web/src/api/graphicsElements.ts web/src/screens/ChannelEditScreen.tsx web/src/screens/ChannelEditScreen.test.tsx
git -c core.hooksPath=/dev/null commit -m "feat(74): channel Branding-tab On Now/Next overlay toggle"
```
---
### Task 6: Live-E2E verification + docs + decision records
**Files:**
- Modify: `docs/domain-model.md`, `docs/channels.md`
- Modify: `docs/decisions.md` (+ `docs/blazor-route-parity.md` only if a route changed — it did not)
- No code (verification + docs)
- [ ] **Step 1: Boot a fresh local instance**
Run: `scripts/e2e-local.sh` (fresh config dir — do NOT reuse an existing one). Confirm startup logs show the graphics-element refresh created the built-in row (grep the log for `on-now-next.yml`).
- [ ] **Step 2: Confirm the built-in element is served**
Run: `curl -s localhost:<port>/api/v1/graphics-elements | jq '.[] | select(.builtIn==true)'`
Expected: one element with `builtIn: true` named "On Now / Next".
- [ ] **Step 3: Enable the overlay on a channel with real playout**
`GET /api/v1/channels/{id}`, then `PUT` the same body with `graphicsElementIds` set to `[<builtInId>]`. Re-`GET` and confirm `graphicsElementIds` round-trips.
- [ ] **Step 4: Stream and visually confirm the burned-in bug**
Curl the channel's HLS (never a browser tab), pull a `.ts`/`.m4s` segment a few seconds after a program boundary, and extract a frame:
```bash
ffmpeg -i <segment> -frames:v 1 /tmp/on-now-next-frame.png
```
Open the PNG and confirm the NOW/NEXT text bug is rendered in the corner. Repeat across a program boundary to confirm the text updates. (This is a visual/operator check — a curl assertion cannot verify pixels.)
- [ ] **Step 5: Note the guide-cache caveat outcome**
If NOW/NEXT is wrong, check guide-cache freshness (`ChannelGuideCacheFolder/{number}.xml`). For a normal channel it should match; if on-demand/time-shift channels drift, file a scoped follow-up issue rather than expanding this PR.
- [ ] **Step 6: Update docs**
- `docs/domain-model.md`: document the channel-level graphics-element attachment (`ChannelGraphicsElement`) and where it's edited (Channel editor → Branding → "Show On Now / Next overlay").
- `docs/channels.md`: add the overlay toggle + its transcode-only limitation (no HLS-Direct).
- `docs/decisions.md`: add a decision record (with `key:`, `Signals:`, `status: active`, `since: 2026-07-22`) for **channel-level graphics-element attachment + the built-in seeded text-element pattern**, cross-referencing `iptv.logo-drives-bug-preset`. Suggested key: `graphics.channel-level-attachment`. Then run `python3 scripts/build_decisions_catalog.py` (or the documented regen) so `docs/decisions/README.md` updates, and validate with `python3 scripts/decisions_validate.py`.
- [ ] **Step 7: Commit docs**
```bash
git add docs/domain-model.md docs/channels.md docs/decisions.md docs/decisions/README.md
git -c core.hooksPath=/dev/null commit -m "docs(74): channel-level graphics attachment + On Now/Next overlay"
```
---
### Task 7: Local gate, independent review, push, PR
- [ ] **Step 1: Full local gate**
```bash
dotnet build ErsatzTV.sln
dotnet test ErsatzTV.Tests
cd web && npm run build && npm run test && cd ..
```
Expected: all green. Investigate any failure before proceeding.
- [ ] **Step 2: BOM + format gate on the touched set**
Run the BOM check and `dotnet format whitespace ... --folder --include <changed .cs>` under `bash -c` (Global Constraints). Fix any reported file.
- [ ] **Step 3: Independent review (MANDATORY)**
This diff touches a DB migration, an API write-path handler, and the render path, and exceeds ~150 C# lines — independent review is required (`process.independent-review-rubric`). Run a cold-context, cross-model review over the full branch diff. Fold fixes as follow-up commits; re-review the fix commit; loop to a clean `Review-verdict: MERGEABLE @ <head-sha>` (or an explicit acceptable-defer with a filed follow-up).
- [ ] **Step 4: Push + open PR + arm CI monitor**
Push the branch once (batch all commits — CI runs can't be cancelled), open a PR (`fixes #74`), and arm the CI monitor on the head sha at PR-open. Ensure #74's issue body carries a `## Done-when` checklist (adversarial-review-passed; tests-green; live-E2E; docs-updated) so the merge-consent gate can derive consent.
- [ ] **Step 5: Session close**
Run the H12 audit (`scripts/issue-qualification-audit.sh`), post a `## Closing record` on #74, remove the `in-progress` label after merge, and run `scripts/refresh-shared-checkout.sh`.
---
## Self-Review
**Spec coverage:** §3.1 join → Task 1; §3.2 selector hook + eager-load → Task 2; §3.3 seed → Task 3; §3.4 API + builtIn → Task 4; §3.5 SPA toggle → Task 5; §5 tests → Tasks 1/2/3/4 (+ E2E Task 6); §6 risks (guide-cache) → Task 6 Step 5; §7 docs → Tasks 4/6; §8 out-of-scope (no live preview) → Task 5 honored. All spec sections map to tasks.
**Deviations from the spec, made explicit:** (a) Row creation is delegated to the existing `RefreshGraphicsElementsHandler` (single owner) rather than done in the seeder — the seeder only materializes the file + marker; this is cleaner ownership and keeps the seeder unit-testable with `MockFileSystem`. (b) The style set uses three named styles (`now`/`sub`/`next`) so NOW title, subtitle, and NEXT line differ visually.
**Placeholder scan:** the only intentionally-open value is the two file-harness lookups called out in Task 4 Step 1 and Task 5 Step 6 (copy the existing test harness / confirm a web test harness exists) — these are "read the neighbor and mirror it" instructions, not code TODOs. The `opacity_expression` is concrete (`LinearFadeDuration(content_seconds, 4, 1, 6)`), verified against `OpacityExpressionHelper`.
**Type consistency:** `GraphicsElementIds` is `List<int>` on the command/request and `int[]` on the response DTO (matches existing DTO array style); `ChannelGraphicsElement` navs and FKs are named identically across entity, config, includes, and reconcile. `builtIn` derives from `GraphicsElementDefaults.OnNowNextFileName` in both the seeder-identity and the API handler.
@@ -0,0 +1,265 @@
# Design — Per-channel "On Now / Next" transient overlay (#74)
- **Issue:** ersatztv#74 ("[Blue sky] On-screen 'On Now / On Next' overlay (channel-surf bug)")
- **Date:** 2026-07-22
- **Status:** approved design, pre-implementation
- **Scope chosen:** full feature — seeded preset element + per-channel SPA toggle + docs + live E2E
## 1. Summary
Burn a transient "On Now / Next" text bug onto a channel's transcoded stream, shown for
~8 seconds at each program transition, for authentic channel-surf feel. The overlay shows the
current program (title + episode/subtitle) and the next program (title + start time).
**Key finding that shaped the design:** the core capability already exists. ErsatzTV has a
SkiaSharp graphics engine (`ErsatzTV.Infrastructure/Streaming/Graphics/`) that renders dynamic
text per playout item, with EPG "current + next N programs" data already wired in as template
variables at transcode time (`TemplateDataRepository.GetEpgTemplateData`). The graphics context
is rebuilt per playout item, so overlay text changes per program automatically. **No new
rendering or EPG-data infrastructure is required.** The remaining work is (a) a **channel-level
attachment** for graphics elements (which does not exist today), (b) a **seeded preset element**,
(c) a **per-channel SPA toggle**, and (d) verification.
The issue's original worry ("requires stream-overlay work in the FFmpeg pipeline beyond the static
watermark — non-trivial") is obsolete: that infrastructure landed (upstream graphics engine +
our #502/#511 remote-image/graphics-engine work). This places #74 alongside #73/#77 — mostly
already-implemented — but the user chose to also ship the reusable channel-attach + toggle rather
than a docs-only close.
## 2. Existing systems (grounding)
- **Graphics elements** (`ErsatzTV.Core/Domain/GraphicsElement.cs`): YAML-authored
(`Text`/`Image`/`Subtitle`/`Motion`/`Script`), rendered by the graphics engine. A DB row has
`Path` (points to a YAML file on disk), `Name`, `Kind`. Attaches only via **PlayoutItem /
ProgramScheduleItem / BlockItem / Deco** join tables — **no channel-level attachment.**
- **`TextGraphicsElement`** (`ErsatzTV.Core/Graphics/TextGraphicsElement.cs`): fields include
`epg_entries:int`, `opacity_expression:string`, `location`, `*_margin_percent`, `width_percent`,
`text_fit`, `text_align`, `z_index`, `base_style`, `styles:[StyleDefinition]`, `text:string`.
`StyleDefinition` carries font size/weight/family, color, halo (color/width/blur), spacing.
- **EPG at transcode time**: `GraphicsElementLoader.InitTemplateVariables`
`ITemplateDataRepository.GetEpgTemplateData(channelNumber, startTime, epgEntries)` reads the
**cached** XMLTV fragment (`FileSystemLayout.ChannelGuideCacheFolder/{channelNumber}.xml`) and
exposes an `Epg` array (`Title`, `SubTitle`, `Description`, `Start`, `Stop`, …). Count is derived
from the element's `epg_entries`.
- **Selection**: `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`
`SelectGraphicsElements(channel, playoutItem, now)`. Returns empty for
`StreamingMode.HttpLiveStreamingDirect`; otherwise resolves deco entries (template deco → playout
deco, each with a `GraphicsElementsMode` of `Merge`/`Override`/`Disable`/`Inherit`) and finally
falls through to `playoutItem.PlayoutItemGraphicsElements`.
- **Refresh/discovery**: `RefreshGraphicsElementsHandler` scans
`FileSystemLayout.GraphicsElements*TemplatesFolder` for `*.yml`/`*.yaml`, inserts a
`GraphicsElement { Path, Kind }` per new file, and **removes rows whose files vanished.** There
are **no seeded/built-in example elements today** — all are user-supplied.
- **#67 precedent** (`iptv.logo-drives-bug-preset`, `docs/decisions.md`): the "Channel Bug"
*watermark* is seeded in `DbInitializer.SeedChannelBugWatermark`, guarded by a `ConfigElement`
marker (`watermark.channel_bug_seeded`), **adopting** any existing row untouched, once per DB.
The editor toggle reflects a server-provided discriminator (`WatermarkResponseModel.imageSource`),
**not** a fragile name-match — a lesson we carry over.
Note: watermarks (`ChannelWatermark`, image-only, direct `Channel.WatermarkId` FK) and graphics
elements are **separate parallel systems**. The text EPG overlay must use the graphics-element
system; it cannot reuse the watermark FK (a channel has only one `WatermarkId`, already used by the
#67 logo-bug — reusing it would collide, and multi-line EPG text is a poor fit for the single-image
watermark model). Hence a new channel-level graphics attachment.
## 3. Components / changes
### 3.1 Domain + persistence — `ChannelGraphicsElement` join (NEW)
A 5th join table, structurally identical to the existing four:
```csharp
// ErsatzTV.Core/Domain/ChannelGraphicsElement.cs
public class ChannelGraphicsElement
{
public int ChannelId { get; set; }
public Channel Channel { get; set; }
public int GraphicsElementId { get; set; }
public GraphicsElement GraphicsElement { get; set; }
}
```
- `Channel` gains `List<ChannelGraphicsElement> ChannelGraphicsElements`.
- `GraphicsElement` gains `List<ChannelGraphicsElement> ChannelGraphicsElements` (+ `List<Channel> Channels` if the existing skip-nav convention is followed — match the `DecoGraphicsElement` config exactly).
- EF configuration mirroring `GraphicsElementConfiguration` / `DecoGraphicsElement` (composite key `{ChannelId, GraphicsElementId}`, cascade on channel delete, restrict/cascade on element delete consistent with the existing joins).
- **Dual-provider migration** via `scripts/add-migration.sh Add_ChannelGraphicsElement` (Sqlite + MySql). Named consistently with the existing `*_Add_DecoGraphicsElements` migrations.
### 3.2 Render-path hook — `GraphicsElementSelector`
Append channel-level elements at the **final fall-through**, alongside
`playoutItem.PlayoutItemGraphicsElements`:
```csharp
result.AddRange(playoutItem.PlayoutItemGraphicsElements);
result.AddRange(channel.ChannelGraphicsElements
.Map(cge => new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = cge.GraphicsElement }));
return result;
```
**Precedence:** channel overlays are a **base layer** — they merge with `Merge`-mode deco elements
and per-playout-item elements. A deco in `Override` or `Disable` mode `return`s before the
fall-through, so it **suppresses** the channel overlay (a deliberate, documented rule: decos can
override channel defaults). `HttpLiveStreamingDirect` continues to return empty early (overlay not
applicable when ErsatzTV isn't transcoding — a documented limitation surfaced in the SPA helper
text).
**Eager-load requirement:** wherever the `Channel` is loaded for the streaming/transcode path
(the query behind `GetPlayoutItemProcessByChannelNumberHandler`
`FFmpegLibraryProcessService.CreateProcess`), add
`.Include(c => c.ChannelGraphicsElements).ThenInclude(cge => cge.GraphicsElement)` so the selector
sees populated navs. Verify no other caller relies on the channel being loaded without this include
(a missing include yields a silent empty list, not a crash — so an explicit test guards it).
### 3.3 Seeded preset element — `on-now-next.yml` (NEW seed pattern)
- Ship `on-now-next.yml` as an embedded/content resource in the app (a `resources/`-style folder
copied at build, or an embedded resource written out at seed time).
- A seeder (extend `DbInitializer`, or a dedicated `IGraphicsElementSeeder` it calls) that, guarded
by a new `ConfigElement` marker `graphics.on_now_next_seeded` (new
`ConfigElementKey.GraphicsOnNowNextSeeded`):
1. If the marker is set, do nothing (once per DB — a deleted preset is **not** resurrected,
matching #67's reasoning that `DbInitializer` runs every startup).
2. Copy the YAML into `FileSystemLayout.GraphicsElementsTextTemplatesFolder/on-now-next.yml`
**only if the file is absent** (never overwrite operator edits — adopt-not-clobber).
3. Ensure a `GraphicsElement` row exists for that path (reuse the row-creation + name-parse logic
from `RefreshGraphicsElementsHandler`; do not duplicate it — factor a shared helper or invoke
the refresh so `Name` is parsed from the YAML `name:` field).
4. Set the marker.
- Idempotency + adopt semantics covered by a test mirroring `DbInitializerChannelBugWatermarkTests`.
**Seed order dependency:** the element row must exist before a channel can reference it. If
`RefreshGraphicsElements` runs only on demand, the seeder must create the row itself (step 3). If it
already runs at startup, ensure the seeder's file-copy precedes it. This ordering is an
implementation checkpoint.
**Seeded YAML (illustrative — finalized during implementation against the loader):**
```yaml
name: On Now / Next
epg_entries: 2
location: BottomLeft
horizontal_margin_percent: 4
vertical_margin_percent: 8
width_percent: 42
text_fit: Wrap
text_align: Left
z_index: 100
# fade in over first ~1s, hold, fade out ~7-8s into the item (contentTime in seconds)
opacity_expression: "<verified against OpacityExpressionHelper during impl>"
base_style: now
styles:
- name: now
font_size: 30
font_weight: 700
text_color: "#FFFFFF"
halo_color: "#000000"
halo_width: 2
- name: next
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 2
text: |
NOW {{ Epg[0].Title }}
{{ Epg[0].SubTitle }}
NEXT {{ Epg[1].Title }} · {{ Epg[1].Start }}
```
The exact `opacity_expression` grammar, the style-span markup that applies `now`/`next` styles to
specific lines, and datetime formatting of `Epg[1].Start` are finalized in implementation by
reading `GraphicsEngine`/`OpacityExpressionHelper`/`TextElement` and the Scriban template helpers —
these are element-authoring details, not architectural unknowns.
### 3.4 API
- `ChannelController` detail + update DTOs gain `graphicsElementIds: int[]` (the channel's attached
graphics elements), backing the join. Update replaces the set (consistent with existing
PUT-replace list conventions).
- `GraphicsElementResponseModel` gains a **server-derived `builtIn: bool`**, computed by comparing
the row's `Path` to the seeded template path — a robust discriminator so the SPA finds the seeded
"On Now / Next" element without a fragile name-match (the direct #67 lesson). (Alternative if a
richer taxonomy is wanted later: a `source` enum; `builtIn` is the YAGNI choice now.)
- Regenerate artifacts: build the app project first, then `./scripts/update-openapi.sh`, then
`npm run generate:api`. This touches `ErsatzTV/Controllers/Api/**` and `ErsatzTV.Core/Api/**`
the blocking `api-docs` CI gate requires the regenerated `v1.json`/`v1.d.ts`/`endpoint-index.md`
in the same diff.
### 3.5 SPA
- `web/src/screens/ChannelEditScreen.tsx`, **Branding tab**, beside the existing "Use logo as
on-screen bug" switch: add a "Show On Now / Next overlay" `Switch`.
- On ⇒ add the `builtIn` element's id (found via the existing `GET /api/v1/graphics-elements`,
filtered by `builtIn === true`) to the channel draft's `graphicsElementIds`.
- Off ⇒ remove it.
- Follows the exact pattern of the logo-bug toggle (`logoBugEnabled` / `findLogoBugWatermark`),
generalized to graphics elements.
- **Scope trim (YAGNI):** no live pixel-preview (the image `BugPreview` renders a static image;
live EPG-text rendering in the browser is disproportionate). Instead a short static caption
describing what shows and noting it applies only to transcoded modes (not HLS-Direct).
## 4. Data flow (enable → render)
1. Operator opens Channel editor → Branding → toggles "Show On Now / Next overlay" on → save.
2. `PUT /api/v1/channels/{id}` includes the seeded element id in `graphicsElementIds` → handler
reconciles the `ChannelGraphicsElement` join set.
3. On stream start, `HlsSessionWorker``GetPlayoutItemProcessByChannelNumberHandler` loads the
channel (with the new include) → `GraphicsElementSelector.SelectGraphicsElements` appends the
channel overlay element (unless a deco Override/Disable suppresses it).
4. `FFmpegLibraryProcessService.CreateProcess` builds the graphics context;
`GraphicsElementLoader` parses the YAML, resolves `Epg[0]`/`Epg[1]` from the cached guide, and
the graphics engine renders the faded text bug into the piped BGRA frames composited by ffmpeg.
5. At the next playout item, a fresh process/context re-resolves NOW/NEXT → the bug updates and
re-fades.
## 5. Testing / verification
- **NUnit (Core/Infrastructure):**
- `GraphicsElementSelector` composition: channel overlay merges on the no-deco and `Merge` paths;
`Override`/`Disable` deco suppresses it; `HttpLiveStreamingDirect` returns empty. (Extends the
existing selector tests.)
- Seeder idempotency/adopt: marker gates once-per-DB; existing file/row adopted untouched; deleted
preset not resurrected (mirror `DbInitializerChannelBugWatermarkTests`).
- Channel update reconciles the `ChannelGraphicsElement` set (add/remove).
- **Live E2E** (`scripts/e2e-local.sh`, fresh config dir): create a channel with real playout,
enable the toggle, stream via curl (never a browser tab), and confirm the burned-in NOW/NEXT bug
renders and changes across a program boundary. Required because this is an API write-path +
render-path change (`release.live-e2e-required`).
- **Independent review is mandatory** (`process.independent-review-rubric`): the diff touches a DB
migration, an API write-path handler, and the render path, and exceeds ~150 C# lines. Cross-model
/ cold-context review before merge.
## 6. Risks / open items
- **Guide-cache freshness governs EPG accuracy** — the overlay reads the same cached XMLTV the guide
serves. Validate on a normal channel. On-demand / time-shifted channels (see #68) may need the
guide refreshed on thaw for the overlay to match; if E2E shows drift there, file a scoped
follow-up rather than expanding this PR.
- **HLS-Direct has no overlay** (ErsatzTV isn't transcoding) — documented limitation, surfaced in
the toggle's caption.
- **Seed file lifecycle** — if an operator deletes the YAML, `RefreshGraphicsElements` removes the
row and channels silently lose the bug (acceptable, mirrors deleting a preset). The marker keeps
it from silently reappearing.
- **PR size** — migration + render path + API + seeding + SPA is a sizable single PR; keep slices
reviewable and land behind the mandatory review.
## 7. Docs to update (same PR)
- `docs/domain-model.md` — channel-level graphics-element attachment (`ChannelGraphicsElement`).
- `docs/api-conventions.md` checklist + regenerated `v1.json` / `endpoint-index.md`
(`release.api-contract-ci-gate`).
- `docs/decisions.md` — new record(s): channel-level graphics attachment + the seeded
graphics-element pattern (with `key:`/`Signals:`), cross-referencing `iptv.logo-drives-bug-preset`.
- `docs/channels.md` — the On Now/Next overlay toggle + its transcode-only limitation.
- `docs/spa-conventions.md` — only if the toggle introduces a new pattern (likely not; it reuses the
logo-bug pattern).
## 8. Out of scope / deferred
- Live pixel-preview of the text overlay in the editor (YAGNI; static caption instead).
- Intra-item text refresh (a program running long won't roll "NEXT" mid-item — text is baked at
item start, which is fine for the transient-on-transition model).
- Per-channel geometry/content customization of the bug (all channels share the seeded preset;
operators can edit the YAML or attach a different element — the join is general).
- Overlay on HLS-Direct.
- Periodic/always-on display modes (only transient-on-transition is built).
+3
View File
@@ -201,6 +201,7 @@ export interface components {
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
"isEnabled": boolean;
"showInEpg": boolean;
"graphicsElementIds": Array<number>;
};
"ChannelGuideChannelResponseModel": {
"number": string;
@@ -761,6 +762,7 @@ export interface components {
"GraphicsElementResponseModel": {
"id": number;
"name": string;
"builtIn": boolean;
};
"GuideMode": "Normal" | "Filler";
"HardwareAccelerationKind": "None" | "Qsv" | "Nvenc" | "Vaapi" | "VideoToolbox" | "Amf" | "V4l2m2m" | "Rkmpp";
@@ -1638,6 +1640,7 @@ export interface components {
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
"isEnabled": boolean;
"showInEpg": boolean;
"graphicsElementIds": null | Array<number>;
};
"UpdateChannelTemplateRequest": {
"name": string;
+6
View File
@@ -0,0 +1,6 @@
import type { GraphicsElement } from './pickers';
// Prefer the server-declared built-in flag; never match on the user-editable name.
export function findBuiltInOnNowNext(elements: GraphicsElement[]): GraphicsElement | null {
return elements.find((e) => e.builtIn) ?? null;
}
+1
View File
@@ -11,6 +11,7 @@ export * from './decos';
export * from './decoTemplates';
export * from './ffmpegProfiles';
export * from './fillerPresets';
export * from './graphicsElements';
export * from './guide';
export * from './imageFolders';
export * from './languages';
+105 -1
View File
@@ -31,7 +31,8 @@ const channel = {
transcodeMode: 'OnDemand',
idleBehavior: 'StopOnDisconnect',
isEnabled: true,
showInEpg: true
showInEpg: true,
graphicsElementIds: []
};
// The PUT body is the GET response minus the read-only id / playoutCount fields.
@@ -52,6 +53,7 @@ function json(body: unknown, status = 200): Response {
interface FetchOptions {
channelOverrides?: Record<string, unknown>;
channelStatus?: number;
graphicsElements?: Record<string, unknown>[];
onPut?: (body: unknown) => void;
putResponseOverrides?: Record<string, unknown>;
watermarks?: Record<string, unknown>[];
@@ -60,6 +62,7 @@ interface FetchOptions {
function mockApi({
channelOverrides = {},
channelStatus = 200,
graphicsElements = [],
onPut,
putResponseOverrides = {},
watermarks = [{ id: 2, name: 'Corner bug', imageSource: 'Custom' }]
@@ -111,6 +114,10 @@ function mockApi({
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
}
if (url.startsWith('/api/v1/graphics-elements')) {
return Promise.resolve(json(graphicsElements));
}
if (url === '/api/v1/channels') {
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
}
@@ -279,6 +286,10 @@ describe('ChannelEditScreen', () => {
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
}
if (url.startsWith('/api/v1/graphics-elements')) {
return Promise.resolve(json([]));
}
if (url === '/api/v1/channels') {
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
}
@@ -512,6 +523,95 @@ describe('ChannelEditScreen', () => {
});
});
describe('show On Now / Next overlay toggle', () => {
it(
'ticks the toggle when the built-in element id is in graphicsElementIds',
async () => {
mockApi({
channelOverrides: { graphicsElementIds: [7] },
graphicsElements: [{ id: 7, name: 'On Now / Next', builtIn: true }]
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
expect(await screen.findByRole('switch', { name: 'Show On Now / Next overlay' })).toHaveAttribute(
'aria-checked',
'true'
);
},
15000
);
it(
'adds the built-in element id to graphicsElementIds on save when switched on',
async () => {
const puts: unknown[] = [];
mockApi({
channelOverrides: { graphicsElementIds: [] },
graphicsElements: [{ id: 7, name: 'On Now / Next', builtIn: true }],
onPut: (body) => puts.push(body)
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
fireEvent.click(await screen.findByRole('switch', { name: 'Show On Now / Next overlay' }));
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect((puts[0] as { graphicsElementIds: number[] }).graphicsElementIds).toEqual([7]);
},
15000
);
it(
'removes the built-in element id from graphicsElementIds on save when switched off',
async () => {
const puts: unknown[] = [];
mockApi({
channelOverrides: { graphicsElementIds: [7] },
graphicsElements: [{ id: 7, name: 'On Now / Next', builtIn: true }],
onPut: (body) => puts.push(body)
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
fireEvent.click(await screen.findByRole('switch', { name: 'Show On Now / Next overlay' }));
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect((puts[0] as { graphicsElementIds: number[] }).graphicsElementIds).toEqual([]);
},
15000
);
it(
'disables the toggle when no built-in element is present',
async () => {
mockApi({ graphicsElements: [] });
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
expect(await screen.findByRole('switch', { name: 'Show On Now / Next overlay' })).toHaveAttribute(
'aria-disabled',
'true'
);
},
15000
);
});
describe('on-screen bug preview', () => {
it('renders the preview image using the fetched watermark geometry', async () => {
mockApi({
@@ -641,6 +741,10 @@ describe('ChannelEditScreen', () => {
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
}
if (url.startsWith('/api/v1/graphics-elements')) {
return Promise.resolve(json([]));
}
if (url === '/api/v1/channels') {
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
}
+74 -18
View File
@@ -15,12 +15,14 @@ import { navigateToPath } from '../routing';
import { Badge, BugPreview, Button, Card, ChannelLogo, Input, Select, Switch, type BugPreviewGeometry } from '../components';
import {
ApiError,
findBuiltInOnNowNext,
findLogoBugWatermark,
getChannelById,
getChannels,
getChannelStreamSelectors,
getFFmpegProfiles,
getFillerPresets,
getGraphicsElements,
getLanguages,
getMusicVideoCreditsTemplates,
getWatermark,
@@ -32,6 +34,7 @@ import {
type ChannelSummary,
type FFmpegProfile,
type FillerPreset,
type GraphicsElement,
type LanguageCode,
type UpdateChannelRequest,
type Watermark
@@ -153,7 +156,8 @@ function draftFromChannel(channel: Channel): UpdateChannelRequest {
transcodeMode: channel.transcodeMode,
idleBehavior: channel.idleBehavior,
isEnabled: channel.isEnabled,
showInEpg: channel.showInEpg
showInEpg: channel.showInEpg,
graphicsElementIds: channel.graphicsElementIds ?? []
};
}
@@ -276,6 +280,7 @@ interface ReferenceData {
channels: ChannelSummary[];
fillerPresets: FillerPreset[];
watermarks: Watermark[];
graphicsElements: GraphicsElement[];
ffmpegProfiles: FFmpegProfile[];
languages: LanguageCode[];
musicVideoCreditsTemplates: string[];
@@ -825,6 +830,35 @@ function BrandingPane({
)}
</div>
</Row>
{(() => {
const onNowNext = findBuiltInOnNowNext(data.graphicsElements);
const ids = draft.graphicsElementIds ?? [];
const enabled = onNowNext != null && ids.includes(onNowNext.id);
return (
<Row
control={340}
help={
onNowNext == null
? 'The built-in On Now / Next element is not available yet.'
: 'Briefly shows the current and next program at each program change. Not shown in HLS Direct mode.'
}
label="Show On Now / Next overlay"
>
<Switch
checked={enabled}
disabled={hlsDirect || onNowNext == null}
hideLabel
label="Show On Now / Next overlay"
onChange={(next) => {
if (onNowNext == null) return;
const without = ids.filter((id) => id !== onNowNext.id);
set({ graphicsElementIds: next ? [...without, onNowNext.id] : without });
}}
size="sm"
/>
</Row>
);
})()}
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Overlay applied to the channel.'} label="Watermark">
<Select
disabled={hlsDirect}
@@ -903,25 +937,47 @@ export function ChannelEditScreen() {
getChannels(),
getLanguages(),
getMusicVideoCreditsTemplates(),
getChannelStreamSelectors()
getChannelStreamSelectors(),
getGraphicsElements()
])
.then(([channel, ffmpegProfiles, watermarks, fillerPresets, channels, languages, musicVideoCreditsTemplates, streamSelectors]) => {
if (!active) {
return;
}
.then(
([
channel,
ffmpegProfiles,
watermarks,
fillerPresets,
channels,
languages,
musicVideoCreditsTemplates,
streamSelectors,
graphicsElements
]) => {
if (!active) {
return;
}
const initial = draftFromChannel(channel);
const initialExternalLogoUrl = externalLogoUrlFromChannel(channel);
setDraft(initial);
setSaved(initial);
setExternalLogoUrl(initialExternalLogoUrl);
setSavedExternalLogoUrl(initialExternalLogoUrl);
setLoaded({
channelId,
data: { channels, ffmpegProfiles, fillerPresets, languages, musicVideoCreditsTemplates, streamSelectors, watermarks },
playoutLocked: channel.playoutSource === 'Generated' && channel.playoutCount > 0
});
})
const initial = draftFromChannel(channel);
const initialExternalLogoUrl = externalLogoUrlFromChannel(channel);
setDraft(initial);
setSaved(initial);
setExternalLogoUrl(initialExternalLogoUrl);
setSavedExternalLogoUrl(initialExternalLogoUrl);
setLoaded({
channelId,
data: {
channels,
ffmpegProfiles,
fillerPresets,
graphicsElements,
languages,
musicVideoCreditsTemplates,
streamSelectors,
watermarks
},
playoutLocked: channel.playoutSource === 'Generated' && channel.playoutCount > 0
});
}
)
.catch((error: unknown) => {
if (!active) {
return;