Files
ersatztv/docs/superpowers/plans/2026-07-22-on-now-next-overlay.md
T

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 (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:

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 Channel and GraphicsElement

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 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".

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 (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:

    // 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:

    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 BuiltIn to 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.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
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):

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.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:

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

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.