42 KiB
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(branchfeat/74-on-now-next-overlay, offorigin/main). Never commit in/Users/timothy/ersatztv. - Any
TvContextmodel change requires a dual-provider migration viascripts/add-migration.sh <Name>(generates BOTH Sqlite and MySql). Never hand-edit migrations. Never setETV_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();thenawait using TvContext context = _db.CreateContext();. - Any
/api/*change: build the app project FIRST, then./scripts/update-openapi.sh, thencd web && npm run generate:api. Commit regeneratedErsatzTV/wwwroot/api/v1.json,web/src/api/generated/v1.d.ts, anddocs/endpoint-index.mdin 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 underbash -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_styleMUST name a style present instyles: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_expressionis NCalc; time params (seconds, doubles):content_seconds,content_total_seconds,channel_seconds,time_of_day_seconds. Helper functions:LinearFadeDuration(time, start, fadeSeconds, peakSeconds)andLinearFadePoints(time, start, peakStart, peakEnd, end). Whenopacity_expressionis set,opacity_percentis ignored.- Built-in element identity is by filename constant
on-now-next.yml(see Task 3GraphicsElementDefaults), 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:
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:
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
ChannelandGraphicsElement
In ErsatzTV.Core/Domain/Channel.cs, alongside public List<Artwork> Artwork { get; set; }:
public List<GraphicsElement> GraphicsElements { get; set; }
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
In ErsatzTV.Core/Domain/GraphicsElement.cs, alongside the Decos / DecoGraphicsElements navs:
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>):
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
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
PlayoutItemGraphicsElements 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.
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:
result.AddRange(playoutItem.PlayoutItemGraphicsElements);
return result;
with:
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):
.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
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 beforeDatabaseIsReady) - 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 ofGraphicsElementrows) 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".
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:
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:
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.
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:
_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
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(addint[] GraphicsElementIds) - Modify:
ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs(addList<int> GraphicsElementIds+ thread throughToCommand) - Modify:
ErsatzTV.Application/Channels/Commands/UpdateChannel.cs(addList<int> GraphicsElementIds) - Modify:
ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs(include + reconcile) - Modify:
ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs(include onGetChannel) - Modify:
ErsatzTV.Application/Channels/Mapper.cs(project ids) - Modify:
ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs(addbool BuiltIn) - Modify:
ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs(derivebuiltIn) - 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
GraphicsElementIdson channel detail + update;BuiltInonGraphicsElementResponseModel. -
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:
// 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
GraphicsElementIdsto the command + request DTO
In ErsatzTV.Application/Channels/Commands/UpdateChannel.cs, add a final positional parameter:
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:
.Include(c => c.ChannelGraphicsElements)
In ApplyUpdateRequest, immediately before await dbContext.SaveChangesAsync(cancellationToken);, reconcile (mirrors the artwork add/remove block):
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):
.Include(c => c.ChannelGraphicsElements)
.ThenInclude(x => x.GraphicsElement)
In ErsatzTV.Application/Channels/Mapper.cs ProjectToDetailResponseModel, add the final constructor argument:
channel.ShowInEpg,
channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? []);
In ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs, add the final record parameter:
bool ShowInEpg,
int[] GraphicsElementIds);
- Step 6: Add
BuiltInto the graphics-element DTO + handler
In ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs:
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):
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:
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.mdchecklist 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
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(findBuiltInOnNowNexthelper) - 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
graphicsElementIdsandGraphicsElementResponseModel.builtIn(Task 4);getGraphicsElements()inweb/src/api/pickers.ts. -
Step 1: Add the built-in finder helper
web/src/api/graphicsElements.ts (mirrors findLogoBugWatermark):
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):
{(() => {
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
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.mdonly 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:
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 (withkey:,Signals:,status: active,since: 2026-07-22) for channel-level graphics-element attachment + the built-in seeded text-element pattern, cross-referencingiptv.logo-drives-bug-preset. Suggested key:graphics.channel-level-attachment. Then runpython3 scripts/build_decisions_catalog.py(or the documented regen) sodocs/decisions/README.mdupdates, and validate withpython3 scripts/decisions_validate.py. -
Step 7: Commit docs
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
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.