# 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 ` (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 ``. - 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`; `Channel.GraphicsElements : List`; `GraphicsElement.ChannelGraphicsElements : List`; `GraphicsElement.Channels : List`. - [ ] **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 { get; set; }`: ```csharp public List GraphicsElements { get; set; } public List ChannelGraphicsElements { get; set; } ``` In `ErsatzTV.Core/Domain/GraphicsElement.cs`, alongside the `Decos` / `DecoGraphicsElements` navs: ```csharp public List Channels { get; set; } public List 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`): ```csharp builder.HasMany(c => c.GraphicsElements) .WithMany(m => m.Channels) .UsingEntity( 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 TemplateDeco, Option 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(); deco.GetDecoEntries(Arg.Any(), Arg.Any()).Returns(entries); return (new GraphicsElementSelector(deco, NullLogger.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.None, Option.None)); List 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.None, Option.None)); List 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.None)); List 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(); 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 GraphicsElementIds` + thread through `ToCommand`) - Modify: `ErsatzTV.Application/Channels/Commands/UpdateChannel.cs` (add `List 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` 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(_db.Options)`-equivalent used elsewhere in `ErsatzTV.Tests/Application/` (grep for `IDbContextFactory` 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 GraphicsElementIds) : IRequest>; ``` In `ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs`, add the matching final record parameter `List 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 ( { if (onNowNext == null) return; const without = ids.filter((id) => id !== onNowNext.id); set({ graphicsElementIds: next ? [...without, onNowNext.id] : without }); }} size="sm" /> ); })()} ``` - [ ] **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:/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 `[]`. 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 -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 ` 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 @ ` (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` 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.