diff --git a/ErsatzTV.Application/Watermarks/Mapper.cs b/ErsatzTV.Application/Watermarks/Mapper.cs index bf3a48cef..acacff654 100644 --- a/ErsatzTV.Application/Watermarks/Mapper.cs +++ b/ErsatzTV.Application/Watermarks/Mapper.cs @@ -1,4 +1,4 @@ -using ErsatzTV.Application.Artworks; +using ErsatzTV.Application.Artworks; using ErsatzTV.Core.Api.Watermarks; using ErsatzTV.Core.Domain; @@ -7,7 +7,7 @@ namespace ErsatzTV.Application.Watermarks; internal static class Mapper { internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) => - new(watermark.Id, watermark.Name); + new(watermark.Id, watermark.Name, watermark.ImageSource); internal static WatermarkFullResponseModel ProjectToFullResponseModel(ChannelWatermark watermark) => new( diff --git a/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs b/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs index 9d0d6435c..c9ed800cc 100644 --- a/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs +++ b/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs @@ -1,4 +1,8 @@ #nullable enable +using ErsatzTV.Core.Domain; + namespace ErsatzTV.Core.Api.Watermarks; -public record WatermarkResponseModel(int Id, string Name); +// ImageSource lets a client identify logo-driven presets (the seeded "Channel Bug") without +// matching a user-editable name. Additive under the frozen /api/v1 contract (#286). +public record WatermarkResponseModel(int Id, string Name, ChannelWatermarkImageSource ImageSource); diff --git a/ErsatzTV.Core/Domain/ConfigElementKey.cs b/ErsatzTV.Core/Domain/ConfigElementKey.cs index 36f5fe611..22b0a5f1a 100644 --- a/ErsatzTV.Core/Domain/ConfigElementKey.cs +++ b/ErsatzTV.Core/Domain/ConfigElementKey.cs @@ -24,6 +24,7 @@ public class ConfigElementKey public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id"); public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id"); public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id"); + public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded"); public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds"); public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit"); public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count"); diff --git a/ErsatzTV.Infrastructure/Data/DbInitializer.cs b/ErsatzTV.Infrastructure/Data/DbInitializer.cs index 822bc9b82..37ebd83d0 100644 --- a/ErsatzTV.Infrastructure/Data/DbInitializer.cs +++ b/ErsatzTV.Infrastructure/Data/DbInitializer.cs @@ -1,8 +1,9 @@ -using System.Globalization; +using System.Globalization; using System.Reflection; using Dapper; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Scheduling; +using ErsatzTV.FFmpeg.State; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Infrastructure.Data; @@ -131,13 +132,17 @@ public static class DbInitializer await context.SaveChangesAsync(cancellationToken); } - await SeedChannelTemplates(context, cancellationToken); + int? channelBugWatermarkId = await SeedChannelBugWatermark(context, cancellationToken); + await SeedChannelTemplates(context, cancellationToken, channelBugWatermarkId); // TODO: create looping static image that mentions configuring via web return Unit.Default; } - private static async Task SeedChannelTemplates(TvContext context, CancellationToken cancellationToken) + private static async Task SeedChannelTemplates( + TvContext context, + CancellationToken cancellationToken, + int? channelBugWatermarkId) { if (await context.ChannelTemplates.AnyAsync(t => t.Name == "Standard", cancellationToken) && await context.ChannelTemplates.AnyAsync(t => t.Name == "Music videos", cancellationToken)) @@ -162,7 +167,8 @@ public static class DbInitializer ChannelMusicVideoCreditsMode.None, ChannelSongVideoMode.Default, shuffleScheduleItems: false, - randomStartPoint: false), + randomStartPoint: false, + channelBugWatermarkId), cancellationToken); await context.SaveChangesAsync(cancellationToken); } @@ -177,7 +183,8 @@ public static class DbInitializer ChannelMusicVideoCreditsMode.GenerateSubtitles, ChannelSongVideoMode.WithProgress, shuffleScheduleItems: true, - randomStartPoint: true), + randomStartPoint: true, + channelBugWatermarkId), cancellationToken); await context.SaveChangesAsync(cancellationToken); } @@ -185,6 +192,60 @@ public static class DbInitializer await EnsureDefaultChannelTemplateConfig(context, cancellationToken); } + // A single shared preset is all that's needed: ImageSource.ChannelLogo resolves each channel's + // own logo artwork at render time (WatermarkSelector), so one row makes every channel use its + // own logo as its on-screen bug. + // + // Guarded by a ConfigElement marker rather than by name alone: ChannelWatermark has no IsSystem + // flag, and Initialize runs on every startup, so a name-only guard would resurrect the row + // forever after a deliberate delete. Adopting an existing same-name row (an operator's tuned + // one) also sets the marker — adopt, never overwrite. + private static async Task SeedChannelBugWatermark( + TvContext context, + CancellationToken cancellationToken) + { + string seededKey = ConfigElementKey.WatermarkChannelBugSeeded.Key; + bool alreadySeeded = await context.ConfigElements + .AnyAsync(c => c.Key == seededKey, cancellationToken); + + ChannelWatermark existing = await context.ChannelWatermarks + .FirstOrDefaultAsync(w => w.Name == "Channel Bug", cancellationToken); + + if (alreadySeeded) + { + return existing?.Id; + } + + if (existing is null) + { + existing = new ChannelWatermark + { + Name = "Channel Bug", + Mode = ChannelWatermarkMode.Permanent, + ImageSource = ChannelWatermarkImageSource.ChannelLogo, + Image = null, + Location = WatermarkLocation.TopLeft, + Size = WatermarkSize.Scaled, + WidthPercent = 5.0, + HorizontalMarginPercent = 1.0, + VerticalMarginPercent = 1.0, + FrequencyMinutes = 0, + DurationSeconds = 0, + Opacity = 80, + PlaceWithinSourceContent = false, + ZIndex = 0 + }; + await context.ChannelWatermarks.AddAsync(existing, cancellationToken); + } + + await context.ConfigElements.AddAsync( + new ConfigElement { Key = seededKey, Value = "true" }, + cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + return existing.Id; + } + private static async Task GetDefaultFFmpegProfile( TvContext context, CancellationToken cancellationToken) @@ -237,7 +298,8 @@ public static class DbInitializer ChannelMusicVideoCreditsMode musicVideoCreditsMode, ChannelSongVideoMode songVideoMode, bool shuffleScheduleItems, - bool randomStartPoint) => + bool randomStartPoint, + int? watermarkId) => new() { Name = name, @@ -260,6 +322,7 @@ public static class DbInitializer IdleBehavior = ChannelIdleBehavior.StopOnDisconnect, ShuffleScheduleItems = shuffleScheduleItems, RandomStartPoint = randomStartPoint, - FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible + FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible, + WatermarkId = watermarkId }; } diff --git a/ErsatzTV.Tests/Application/Watermarks/WatermarkHandlerTests.cs b/ErsatzTV.Tests/Application/Watermarks/WatermarkHandlerTests.cs index 173ce4798..bcb3fb642 100644 --- a/ErsatzTV.Tests/Application/Watermarks/WatermarkHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Watermarks/WatermarkHandlerTests.cs @@ -24,7 +24,7 @@ public class WatermarkHandlerTests public async Task GetAllWatermarksForApi_Should_Return_All_Watermarks() { await SeedWatermark(1, "Bug"); - await SeedWatermark(2, "Logo"); + await SeedWatermark(2, "Logo", ChannelWatermarkImageSource.ChannelLogo); var handler = new GetAllWatermarksForApiHandler(_db.Factory); @@ -32,8 +32,10 @@ public class WatermarkHandlerTests await handler.Handle(new GetAllWatermarksForApi(), CancellationToken.None); result.Count.ShouldBe(2); - result.ShouldContain(new WatermarkResponseModel(1, "Bug")); - result.ShouldContain(new WatermarkResponseModel(2, "Logo")); + // Distinct image sources so the assertion proves ImageSource is actually carried through + // the mapper, rather than matching a constant on both rows. + result.ShouldContain(new WatermarkResponseModel(1, "Bug", ChannelWatermarkImageSource.Custom)); + result.ShouldContain(new WatermarkResponseModel(2, "Logo", ChannelWatermarkImageSource.ChannelLogo)); } [Test] @@ -47,7 +49,10 @@ public class WatermarkHandlerTests result.ShouldBeEmpty(); } - private async Task SeedWatermark(int id, string name) + private async Task SeedWatermark( + int id, + string name, + ChannelWatermarkImageSource imageSource = ChannelWatermarkImageSource.Custom) { await using TvContext context = _db.CreateContext(); context.ChannelWatermarks.Add(new ChannelWatermark @@ -55,7 +60,7 @@ public class WatermarkHandlerTests Id = id, Name = name, Mode = ChannelWatermarkMode.Permanent, - ImageSource = ChannelWatermarkImageSource.Custom, + ImageSource = imageSource, Image = "watermark.png", Location = WatermarkLocation.BottomRight, Size = WatermarkSize.Scaled, diff --git a/ErsatzTV.Tests/Application/Watermarks/WatermarkMapperTests.cs b/ErsatzTV.Tests/Application/Watermarks/WatermarkMapperTests.cs new file mode 100644 index 000000000..1796ac92d --- /dev/null +++ b/ErsatzTV.Tests/Application/Watermarks/WatermarkMapperTests.cs @@ -0,0 +1,28 @@ +using ErsatzTV.Core.Api.Watermarks; +using ErsatzTV.Core.Domain; +using NUnit.Framework; +using Shouldly; +using static ErsatzTV.Application.Watermarks.Mapper; + +namespace ErsatzTV.Tests.Application.Watermarks; + +[TestFixture] +public class WatermarkMapperTests +{ + [Test] + public void ProjectToResponseModel_Should_Carry_ImageSource() + { + var watermark = new ChannelWatermark + { + Id = 7, + Name = "Channel Bug", + ImageSource = ChannelWatermarkImageSource.ChannelLogo + }; + + WatermarkResponseModel result = ProjectToResponseModel(watermark); + + result.Id.ShouldBe(7); + result.Name.ShouldBe("Channel Bug"); + result.ImageSource.ShouldBe(ChannelWatermarkImageSource.ChannelLogo); + } +} diff --git a/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs b/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs index 8994de194..9c128fc3b 100644 --- a/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs @@ -63,8 +63,8 @@ public class WatermarkControllerTests { List models = [ - new WatermarkResponseModel(1, "Corner Logo"), - new WatermarkResponseModel(2, "Ticker") + new WatermarkResponseModel(1, "Corner Logo", ChannelWatermarkImageSource.Custom), + new WatermarkResponseModel(2, "Ticker", ChannelWatermarkImageSource.ChannelLogo) ]; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(models); diff --git a/ErsatzTV.Tests/Infrastructure/DbInitializerChannelBugWatermarkTests.cs b/ErsatzTV.Tests/Infrastructure/DbInitializerChannelBugWatermarkTests.cs new file mode 100644 index 000000000..e89e0e88d --- /dev/null +++ b/ErsatzTV.Tests/Infrastructure/DbInitializerChannelBugWatermarkTests.cs @@ -0,0 +1,151 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.FFmpeg.State; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Infrastructure; + +[TestFixture] +public class DbInitializerChannelBugWatermarkTests +{ + 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 Initialize_Should_Seed_Channel_Bug_Watermark() + { + await using TvContext context = _db.CreateContext(); + + await DbInitializer.Initialize(context, CancellationToken.None); + + ChannelWatermark watermark = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug"); + watermark.Mode.ShouldBe(ChannelWatermarkMode.Permanent); + watermark.ImageSource.ShouldBe(ChannelWatermarkImageSource.ChannelLogo); + watermark.Image.ShouldBeNull(); + watermark.Location.ShouldBe(WatermarkLocation.TopLeft); + watermark.Size.ShouldBe(WatermarkSize.Scaled); + watermark.WidthPercent.ShouldBe(5.0); + watermark.HorizontalMarginPercent.ShouldBe(1.0); + watermark.VerticalMarginPercent.ShouldBe(1.0); + watermark.Opacity.ShouldBe(80); + watermark.ZIndex.ShouldBe(0); + watermark.PlaceWithinSourceContent.ShouldBeFalse(); + } + + // The production instance already has a hand-made "Channel Bug" row with tuned geometry. + // Seeding must adopt it untouched, never overwrite it and never duplicate it. + [Test] + public async Task Initialize_Should_Adopt_Existing_Channel_Bug_Watermark_Untouched() + { + await using TvContext context = _db.CreateContext(); + await context.ChannelWatermarks.AddAsync( + new ChannelWatermark + { + Name = "Channel Bug", + Mode = ChannelWatermarkMode.Intermittent, + ImageSource = ChannelWatermarkImageSource.ChannelLogo, + Location = WatermarkLocation.BottomRight, + Size = WatermarkSize.Scaled, + WidthPercent = 12, + HorizontalMarginPercent = 3, + VerticalMarginPercent = 4, + Opacity = 55, + FrequencyMinutes = 10, + DurationSeconds = 20 + }); + await context.SaveChangesAsync(); + + await DbInitializer.Initialize(context, CancellationToken.None); + + ChannelWatermark watermark = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug"); + watermark.Mode.ShouldBe(ChannelWatermarkMode.Intermittent); + watermark.Location.ShouldBe(WatermarkLocation.BottomRight); + watermark.WidthPercent.ShouldBe(12); + watermark.Opacity.ShouldBe(55); + } + + [Test] + public async Task Initialize_Should_Be_Idempotent() + { + await using TvContext context = _db.CreateContext(); + + await DbInitializer.Initialize(context, CancellationToken.None); + await DbInitializer.Initialize(context, CancellationToken.None); + + context.ChannelWatermarks.Count(w => w.Name == "Channel Bug").ShouldBe(1); + } + + // No IsSystem flag exists on ChannelWatermark, and Initialize runs on every startup, so without + // a seed marker a deliberate delete would be undone forever. + [Test] + public async Task Initialize_Should_Not_Resurrect_A_Deleted_Preset() + { + await using TvContext context = _db.CreateContext(); + await DbInitializer.Initialize(context, CancellationToken.None); + + ChannelWatermark seeded = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug"); + context.ChannelWatermarks.Remove(seeded); + await context.SaveChangesAsync(); + + await DbInitializer.Initialize(context, CancellationToken.None); + + context.ChannelWatermarks.Any(w => w.Name == "Channel Bug").ShouldBeFalse(); + } + + // The production sequence is ADOPT (an existing hand-made row) and then, possibly, delete — + // not seed-then-delete. The marker is written on the adopt path too, so this must not resurrect. + [Test] + public async Task Initialize_Should_Not_Resurrect_An_Adopted_Preset_After_Deletion() + { + await using TvContext context = _db.CreateContext(); + await context.ChannelWatermarks.AddAsync( + new ChannelWatermark + { + Name = "Channel Bug", + Mode = ChannelWatermarkMode.Permanent, + ImageSource = ChannelWatermarkImageSource.ChannelLogo, + Location = WatermarkLocation.BottomRight, + Size = WatermarkSize.Scaled, + WidthPercent = 12, + Opacity = 55 + }); + await context.SaveChangesAsync(); + + // First run adopts the existing row and records the marker. + await DbInitializer.Initialize(context, CancellationToken.None); + + ChannelWatermark adopted = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug"); + context.ChannelWatermarks.Remove(adopted); + await context.SaveChangesAsync(); + + await DbInitializer.Initialize(context, CancellationToken.None); + + context.ChannelWatermarks.Any(w => w.Name == "Channel Bug").ShouldBeFalse(); + } + + [Test] + public async Task Initialize_Should_Stamp_The_Preset_On_Freshly_Seeded_Templates() + { + await using TvContext context = _db.CreateContext(); + + await DbInitializer.Initialize(context, CancellationToken.None); + + ChannelWatermark watermark = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug"); + List templates = context.ChannelTemplates.Where(t => t.IsSystem).ToList(); + + // Assert the collection is non-empty FIRST: a bare foreach over zero rows passes vacuously, + // so this test would stay green if template seeding silently bailed out. + templates.Count.ShouldBe(2); + foreach (ChannelTemplate template in templates) + { + template.WatermarkId.ShouldBe(watermark.Id); + } + } +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index e0c8ec4d3..646a9e169 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -32656,7 +32656,8 @@ "WatermarkResponseModel": { "required": [ "id", - "name" + "name", + "imageSource" ], "type": "object", "properties": { @@ -32666,6 +32667,9 @@ }, "name": { "type": "string" + }, + "imageSource": { + "$ref": "#/components/schemas/ChannelWatermarkImageSource" } } }, diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 85ae19d5c..c2eb4e665 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -289,6 +289,10 @@ returns `null` when the channel has no logo so the SPA falls back to its generat raw un-rooted `{path, contentType}` form is still used only by the channel **editor** DTO (`ChannelDetailResponseModel.Logo`), which round-trips it back on save. +`GET /api/v1/watermarks` returns picker-grade rows that carry `imageSource` alongside `id`/`name` +(#67), so a client can find the seeded logo-driven `Channel Bug` preset without matching its +user-editable name. The full geometry still requires `GET /api/v1/watermarks/{id}`. + ### 4a. Artwork content type is sniffed, never client-supplied (issue #283) The uploaded-artwork surfaces (channel logo, watermark) must never trust a client-declared content diff --git a/docs/channels.md b/docs/channels.md index 0207c7d59..de4ab94d0 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -139,6 +139,35 @@ The scheduling engine (`ErsatzTV.Core/Scheduling/`) resolves schedule items into ## Watermarks -`ChannelWatermark` supports modes: Permanent, Intermittent, OpacityExpression. Image sources: custom upload, channel logo, or built-in resource. Positioned with percentage-based margins and z-index. +`ChannelWatermark` supports modes: Permanent, Intermittent, OpacityExpression. Image sources: custom +upload, channel logo, or built-in resource. Positioned with percentage-based margins and z-index. -Note: `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` for watermark logo fetching — see issue #1 for details. +A watermark is a **shared, named entity** (unique `Name`), referenced by playout items, schedule +items, block items and decos; a channel points at one via `Channel.WatermarkId`. It is not +per-channel state. + +`DbInitializer` seeds one shared preset named **`Channel Bug`** (`ImageSource = ChannelLogo`, +Permanent, TopLeft, Scaled 5% width, 1%/1% margins, 80% opacity). Because `ChannelLogo` resolves +each channel's own `ArtworkKind.Logo` artwork at render time, this single row makes every channel +that points at it use its own logo as its on-screen bug — one uploaded image drives both the guide +listing and the bug (#67). An existing `Channel Bug` row is adopted untouched, never overwritten, and +a `ConfigElement` marker (`watermark.channel_bug_seeded`) makes the seed run once per database, so a +deliberately deleted preset is not resurrected on the next restart. + +Quick-add channel creation defaults to the preset (if the preset has been deleted, creation falls +back to no watermark rather than failing), and the channel editor's Branding tab exposes it as a +"Use logo as on-screen bug" toggle. The default applies to **newly created** channels — an existing +channel with no watermark does not gain one retroactively when a logo is uploaded; flip the toggle. +On a **fresh** install the seed also stamps the preset onto +the system channel templates it creates, so the library-to-lineup builder (which inherits +`WatermarkId` from the selected template) gets the default too. On an existing install the templates +are left alone, so builder-created and auto-tuned channels there inherit whatever the template +already specifies. + +**Limitation:** a logo set via **External logo URL** cannot drive the bug. `WatermarkSelector` +resolves it to the URL and then `File.Exists`-checks it, which is never true, so the watermark is +silently dropped — the URL wins for the guide listing but disables the on-screen bug. Tracked as +**#502**; the editor does not offer a bug preview in that case. + +Note: `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` for watermark logo +fetching — see issue #1 for details. diff --git a/docs/decisions.md b/docs/decisions.md index a6208807f..120311eaa 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -2361,3 +2361,43 @@ fixed here: sweep. This is *why* it cannot cross-delete in a mixed library, but it also means a music video removed from Jellyfin is never removed from ErsatzTV. Pre-existing and orthogonal to this change; it belongs with that scanner's other gaps (no `ItemId`/`Etag`, path-keyed identity — see #488). + +## 2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67) + +#67 asked that one uploaded image drive both the listing logo and the on-screen bug, separably +overridable, with preview. Most of it already existed: `ChannelWatermarkImageSource.ChannelLogo` +resolves the channel's own logo artwork at render time at all three watermark precedence levels, and +`Custom` already provides the independent override. Production had already been running exactly this +pattern by hand — 43 channels pointing at one hand-made `Channel Bug` preset. + +**Decision: seed that preset rather than add per-channel bug columns.** A watermark is a shared named +entity, so per-channel geometry would need either a dual-provider migration or one watermark row per +channel (under a unique-name index). Since `ChannelLogo` resolves per channel at render time, a single +shared row already delivers the user-visible behavior with no schema change. + +- The seed **adopts** an existing `Channel Bug` row untouched, so an operator's tuned geometry is never + overwritten, and a `ConfigElement` marker (`watermark.channel_bug_seeded`) makes it run once per + database rather than once per name-absence — `ChannelWatermark` has no `IsSystem` flag and + `DbInitializer.Initialize` runs at every startup, so a name-only guard would resurrect a deliberately + deleted preset forever. Covered by `DbInitializerChannelBugWatermarkTests`. +- `WatermarkResponseModel` gained `imageSource` (additive under the frozen `/api/v1`, #286) so clients + identify logo-driven presets generically instead of matching a user-editable name. Note the limit: + once a *second* logo-driven preset exists, `imageSource` identifies the class but not *the* default, + so `findLogoBugWatermark` prefers the seeded name as a deterministic tiebreak. +- The default is applied by the SPA's quick-add **creation** path, not by inferring "newness" in the + editor — quick-add creates through the API and then navigates to the editor, so the editor only ever + loads an existing row. The editor toggle reflects the **referenced** watermark's `imageSource`; an + earlier draft searched the list instead, which (because `getWatermarks()` sorts by name) would have + silently repointed channels bound to a non-first logo-driven preset. Caught in independent review + and pinned by a regression test. +- The builder flow is covered on **fresh installs only**, by stamping the preset onto the system + channel templates the seed itself creates; existing installs' templates are never mutated. +- Server-side create defaulting was rejected: making an omitted `watermarkId` mean "give me a + watermark" would surprise machine clients of the frozen API. `POST /api/v1/channels/auto-tune` is + therefore unchanged. +- **Not fixed here:** external-URL logos never render a bug (`WatermarkSelector` `File.Exists`-checks a + URL). Pre-existing, lands in the FFmpeg render path, tracked as **#502**. This change only stops the + preview from promising it. + +**Accepted trade-off:** every channel on the shared preset shares one geometry; per-channel tweaks mean +creating a second preset on the Watermarks screen. diff --git a/docs/superpowers/plans/2026-07-20-unified-logo-bug.md b/docs/superpowers/plans/2026-07-20-unified-logo-bug.md new file mode 100644 index 000000000..15699ba3e --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-unified-logo-bug.md @@ -0,0 +1,1206 @@ +# Unified logo / on-screen bug (ersatztv#67) 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:** A single uploaded channel logo drives both the guide listing and the on-screen bug by default, with the geometry visible before saving. + +**Architecture:** No schema change. `ChannelWatermarkImageSource.ChannelLogo` already resolves each channel's own logo artwork at render time, so we seed ONE shared `Channel Bug` watermark preset idempotently in `DbInitializer`, point the SPA's channel-creation paths at it, and add a shared `` component that renders the resolved geometry in both the channel editor and the watermarks screen. One additive DTO field (`WatermarkResponseModel.imageSource`) lets the SPA find logo-driven presets without matching a user-editable name. + +**Tech Stack:** C# / .NET 10, EF Core (dual-provider, but **no migration here**), MediatR CQRS, NUnit + Shouldly + `InMemoryTvContext`; React 19 + TypeScript + Vite, vitest + @testing-library/react. + +**Spec:** `docs/superpowers/specs/2026-07-20-unified-logo-bug-design.md` + +## Global Constraints + +- **Worktree:** `/Users/timothy/.claude/worktrees/etv67`, branch `feat/67-unified-logo-bug`, branched off `origin/main`. Never commit in `/Users/timothy/ersatztv` (shared mutable tree). +- **Test framework is NUnit + Shouldly + NSubstitute.** Never xUnit. +- **`/api/v1` is frozen-additive** (`docs/decisions.md`, #286): add fields, never remove or reshape. +- **No database migration.** If you find yourself running `scripts/add-migration.sh`, stop — the design explicitly rejected that path. +- **BOM gate (#311, fix-as-you-touch).** Two files this plan modifies currently carry a UTF-8 BOM and MUST be written back without one: `ErsatzTV.Infrastructure/Data/DbInitializer.cs` and `ErsatzTV.Application/Watermarks/Mapper.cs`. Check before every push: + ```bash + for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done + ``` +- **Never set `ETV_UPDATE_GOLDENS`.** +- **Exact seed values** (adopted verbatim from the operator's proven production row — do not "improve" them): + `Name="Channel Bug"`, `Mode=ChannelWatermarkMode.Permanent`, `ImageSource=ChannelWatermarkImageSource.ChannelLogo`, `Image=null`, `Location=WatermarkLocation.TopLeft`, `Size=WatermarkSize.Scaled`, `WidthPercent=5.0`, `HorizontalMarginPercent=1.0`, `VerticalMarginPercent=1.0`, `FrequencyMinutes=0`, `DurationSeconds=0`, `Opacity=80`, `PlaceWithinSourceContent=false`, `ZIndex=0`. +- **SPA field names differ from the C# ones** — the wire DTO uses `width`, `horizontalMargin`, `verticalMargin` (NOT `widthPercent`/`*Percent`). Use the wire names in all TypeScript. + +**Verification gate (run before every commit):** +```bash +# backend, from the worktree root +dotnet build ErsatzTV.sln +dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj +# frontend, from web/ +npm test && npm run lint && npm run typecheck && npm run build +``` + +--- + +### Task 1: Seed the shared `Channel Bug` watermark preset + +**Files:** +- Modify: `ErsatzTV.Core/Domain/ConfigElementKey.cs` (add one key property) +- Modify: `ErsatzTV.Infrastructure/Data/DbInitializer.cs:134` (call order), `SeedChannelTemplates` (`:140-186`, takes the new id), `NewSystemTemplate` (`:233-265`, stamps it); append the new seed method +- Test: `ErsatzTV.Tests/Infrastructure/DbInitializerChannelBugWatermarkTests.cs` (create) + +**Interfaces:** +- Consumes: `TvContext.ChannelWatermarks` (`ErsatzTV.Infrastructure/Data/TvContext.cs:44`), `ChannelWatermark` (`ErsatzTV.Core/Domain/ChannelWatermark.cs:6-36`), enums `ChannelWatermarkMode` / `ChannelWatermarkImageSource` (same file, `:38-52`), `WatermarkLocation` / `WatermarkSize` (`ErsatzTV.FFmpeg/State/WatermarkState.cs`), `ChannelTemplate.WatermarkId` (`ErsatzTV.Core/Domain/ChannelTemplate.cs:14`). +- Produces: `private static async Task SeedChannelBugWatermark(TvContext, CancellationToken)` returning the preset's id; a `ChannelWatermark` row named `Channel Bug` with `ImageSource = ChannelLogo`; freshly seeded `ChannelTemplate` rows carrying its `WatermarkId`; a `ConfigElement` row keyed `watermark.channel_bug_seeded`. + +**Why the marker and the ordering (do not simplify these away):** `ChannelWatermark` has no `IsSystem` flag, so a pure name-guard resurrects the row on every restart after a deliberate delete — `DbInitializer.Initialize` runs at every startup (`ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs:89`). The watermark must be seeded **before** the templates so its id can be stamped on them. + +- [ ] **Step 1: Write the failing tests** + +Create `ErsatzTV.Tests/Infrastructure/DbInitializerChannelBugWatermarkTests.cs`: + +```csharp +using ErsatzTV.Core.Domain; +using ErsatzTV.FFmpeg.State; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Infrastructure; + +[TestFixture] +public class DbInitializerChannelBugWatermarkTests +{ + 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 Initialize_Should_Seed_Channel_Bug_Watermark() + { + await using TvContext context = _db.CreateContext(); + + await DbInitializer.Initialize(context, CancellationToken.None); + + ChannelWatermark watermark = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug"); + watermark.Mode.ShouldBe(ChannelWatermarkMode.Permanent); + watermark.ImageSource.ShouldBe(ChannelWatermarkImageSource.ChannelLogo); + watermark.Image.ShouldBeNull(); + watermark.Location.ShouldBe(WatermarkLocation.TopLeft); + watermark.Size.ShouldBe(WatermarkSize.Scaled); + watermark.WidthPercent.ShouldBe(5.0); + watermark.HorizontalMarginPercent.ShouldBe(1.0); + watermark.VerticalMarginPercent.ShouldBe(1.0); + watermark.Opacity.ShouldBe(80); + watermark.ZIndex.ShouldBe(0); + watermark.PlaceWithinSourceContent.ShouldBeFalse(); + } + + // The production instance already has a hand-made "Channel Bug" row with tuned geometry. + // Seeding must adopt it untouched, never overwrite it and never duplicate it. + [Test] + public async Task Initialize_Should_Adopt_Existing_Channel_Bug_Watermark_Untouched() + { + await using TvContext context = _db.CreateContext(); + await context.ChannelWatermarks.AddAsync( + new ChannelWatermark + { + Name = "Channel Bug", + Mode = ChannelWatermarkMode.Intermittent, + ImageSource = ChannelWatermarkImageSource.ChannelLogo, + Location = WatermarkLocation.BottomRight, + Size = WatermarkSize.Scaled, + WidthPercent = 12, + HorizontalMarginPercent = 3, + VerticalMarginPercent = 4, + Opacity = 55, + FrequencyMinutes = 10, + DurationSeconds = 20 + }); + await context.SaveChangesAsync(); + + await DbInitializer.Initialize(context, CancellationToken.None); + + ChannelWatermark watermark = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug"); + watermark.Mode.ShouldBe(ChannelWatermarkMode.Intermittent); + watermark.Location.ShouldBe(WatermarkLocation.BottomRight); + watermark.WidthPercent.ShouldBe(12); + watermark.Opacity.ShouldBe(55); + } + + [Test] + public async Task Initialize_Should_Be_Idempotent() + { + await using TvContext context = _db.CreateContext(); + + await DbInitializer.Initialize(context, CancellationToken.None); + await DbInitializer.Initialize(context, CancellationToken.None); + + context.ChannelWatermarks.Count(w => w.Name == "Channel Bug").ShouldBe(1); + } + + // No IsSystem flag exists on ChannelWatermark, and Initialize runs on every startup, so without + // a seed marker a deliberate delete would be undone forever. + [Test] + public async Task Initialize_Should_Not_Resurrect_A_Deleted_Preset() + { + await using TvContext context = _db.CreateContext(); + await DbInitializer.Initialize(context, CancellationToken.None); + + ChannelWatermark seeded = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug"); + context.ChannelWatermarks.Remove(seeded); + await context.SaveChangesAsync(); + + await DbInitializer.Initialize(context, CancellationToken.None); + + context.ChannelWatermarks.Any(w => w.Name == "Channel Bug").ShouldBeFalse(); + } + + [Test] + public async Task Initialize_Should_Stamp_The_Preset_On_Freshly_Seeded_Templates() + { + await using TvContext context = _db.CreateContext(); + + await DbInitializer.Initialize(context, CancellationToken.None); + + ChannelWatermark watermark = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug"); + foreach (ChannelTemplate template in context.ChannelTemplates.Where(t => t.IsSystem).ToList()) + { + template.WatermarkId.ShouldBe(watermark.Id); + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter FullyQualifiedName~DbInitializerChannelBugWatermarkTests +``` +Expected: FAIL — `Sequence contains no elements` from `.Single(...)` (no seeded row yet). + +- [ ] **Step 3: Implement the seed** + +**3a.** In `ErsatzTV.Core/Domain/ConfigElementKey.cs`, add the key next to `ChannelTemplatesDefaultTemplateId` (`:26`), matching the file's existing property style: + +```csharp + public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded"); +``` + +**3b.** In `ErsatzTV.Infrastructure/Data/DbInitializer.cs`, replace the single call at `:134`. The watermark seeds **first** so its id can be stamped on the templates: + +```csharp + int? channelBugWatermarkId = await SeedChannelBugWatermark(context, cancellationToken); + await SeedChannelTemplates(context, cancellationToken, channelBugWatermarkId); +``` + +**3c.** Add this private method directly after `SeedChannelTemplates` (which ends at `:186`): + +```csharp + // A single shared preset is all that's needed: ImageSource.ChannelLogo resolves each channel's + // own logo artwork at render time (WatermarkSelector), so one row makes every channel use its + // own logo as its on-screen bug. + // + // Guarded by a ConfigElement marker rather than by name alone: ChannelWatermark has no IsSystem + // flag, and Initialize runs on every startup, so a name-only guard would resurrect the row + // forever after a deliberate delete. Adopting an existing same-name row (an operator's tuned + // one) also sets the marker — adopt, never overwrite. + private static async Task SeedChannelBugWatermark( + TvContext context, + CancellationToken cancellationToken) + { + string seededKey = ConfigElementKey.WatermarkChannelBugSeeded.Key; + bool alreadySeeded = await context.ConfigElements + .AnyAsync(c => c.Key == seededKey, cancellationToken); + + ChannelWatermark existing = await context.ChannelWatermarks + .FirstOrDefaultAsync(w => w.Name == "Channel Bug", cancellationToken); + + if (alreadySeeded) + { + return existing?.Id; + } + + if (existing is null) + { + existing = new ChannelWatermark + { + Name = "Channel Bug", + Mode = ChannelWatermarkMode.Permanent, + ImageSource = ChannelWatermarkImageSource.ChannelLogo, + Image = null, + Location = WatermarkLocation.TopLeft, + Size = WatermarkSize.Scaled, + WidthPercent = 5.0, + HorizontalMarginPercent = 1.0, + VerticalMarginPercent = 1.0, + FrequencyMinutes = 0, + DurationSeconds = 0, + Opacity = 80, + PlaceWithinSourceContent = false, + ZIndex = 0 + }; + await context.ChannelWatermarks.AddAsync(existing, cancellationToken); + } + + await context.ConfigElements.AddAsync( + new ConfigElement { Key = seededKey, Value = "true" }, + cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + return existing.Id; + } +``` + +**3d.** Thread the id through the template seed. Change the signature at `:140`: + +```csharp + private static async Task SeedChannelTemplates( + TvContext context, + CancellationToken cancellationToken, + int? channelBugWatermarkId) +``` + +Pass `channelBugWatermarkId` as a new final argument to **both** `NewSystemTemplate(...)` calls (`:157-166` and `:172-181`), add the matching parameter to `NewSystemTemplate` (`:233-240`): + +```csharp + bool randomStartPoint, + int? watermarkId) => +``` + +and add one line to its object initializer (`:241-264`): + +```csharp + WatermarkId = watermarkId, +``` + +This only ever touches templates the seed is creating for the first time — the existing name-guards at `:142-147` and `:155`/`:170` mean an install that already has these templates is untouched. + +Add `using ErsatzTV.FFmpeg.State;` to the file's usings if `WatermarkLocation`/`WatermarkSize` do not already resolve. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter FullyQualifiedName~DbInitializerChannelBugWatermarkTests +dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter FullyQualifiedName~DbInitializerChannelTemplateTests +``` +Expected: PASS, 5 tests in the first fixture; the pre-existing template fixture must still pass (you changed its seed signature). Confirm the build output shows `Build succeeded` / `0 Error(s)` — a failed build with `--no-build` elsewhere can silently run a stale dll. + +- [ ] **Step 5: Strip the BOM and commit** + +```bash +python3 - <<'EOF' +p = 'ErsatzTV.Infrastructure/Data/DbInitializer.cs' +b = open(p, 'rb').read() +if b.startswith(b'\xef\xbb\xbf'): + open(p, 'wb').write(b[3:]) + print('stripped', p) +EOF +head -c3 ErsatzTV.Infrastructure/Data/DbInitializer.cs | xxd -p # must NOT be efbbbf +git add ErsatzTV.Core/Domain/ConfigElementKey.cs ErsatzTV.Infrastructure/Data/DbInitializer.cs ErsatzTV.Tests/Infrastructure/DbInitializerChannelBugWatermarkTests.cs +git commit -m "feat(67): seed shared Channel Bug watermark preset, once per database" +``` + +--- + +### Task 2: Expose `imageSource` on the watermark picker DTO + +**Files:** +- Modify: `ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs` (whole file, 4 lines) +- Modify: `ErsatzTV.Application/Watermarks/Mapper.cs:9-10` (`ProjectToResponseModel`) +- Modify (generated, do not hand-edit): `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, `web/src/api/generated/v1.d.ts` +- Test: `ErsatzTV.Tests/Application/Watermarks/WatermarkMapperTests.cs` (create) + +**Interfaces:** +- Consumes: `ChannelWatermark.ImageSource`. +- Produces: `WatermarkResponseModel(int Id, string Name, ChannelWatermarkImageSource ImageSource)`, surfacing as `Watermark.imageSource` (`"Custom" | "ChannelLogo" | "Resource"`) in `web/src/api/pickers.ts`. Tasks 5 and 6 consume this. + +- [ ] **Step 1: Write the failing test** + +Create `ErsatzTV.Tests/Application/Watermarks/WatermarkMapperTests.cs`: + +```csharp +using ErsatzTV.Core.Api.Watermarks; +using ErsatzTV.Core.Domain; +using NUnit.Framework; +using Shouldly; +using static ErsatzTV.Application.Watermarks.Mapper; + +namespace ErsatzTV.Tests.Application.Watermarks; + +[TestFixture] +public class WatermarkMapperTests +{ + [Test] + public void ProjectToResponseModel_Should_Carry_ImageSource() + { + var watermark = new ChannelWatermark + { + Id = 7, + Name = "Channel Bug", + ImageSource = ChannelWatermarkImageSource.ChannelLogo + }; + + WatermarkResponseModel result = ProjectToResponseModel(watermark); + + result.Id.ShouldBe(7); + result.Name.ShouldBe("Channel Bug"); + result.ImageSource.ShouldBe(ChannelWatermarkImageSource.ChannelLogo); + } +} +``` + +`Mapper` is `internal static`, but `ErsatzTV.Application.csproj:30-32` already exposes internals to `ErsatzTV.Tests` via an `InternalsVisibleTo` assembly attribute — verified, so this test compiles as written. Do **not** add another `InternalsVisibleTo`. + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter FullyQualifiedName~WatermarkMapperTests +``` +Expected: FAIL to compile — `WatermarkResponseModel` does not contain a definition for `ImageSource`. + +- [ ] **Step 3: Add the field** + +`ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs` becomes: + +```csharp +#nullable enable +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.Watermarks; + +// ImageSource lets a client identify logo-driven presets (the seeded "Channel Bug") without +// matching a user-editable name. Additive under the frozen /api/v1 contract (#286). +public record WatermarkResponseModel(int Id, string Name, ChannelWatermarkImageSource ImageSource); +``` + +`ErsatzTV.Application/Watermarks/Mapper.cs:9-10` becomes: + +```csharp + internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) => + new(watermark.Id, watermark.Name, watermark.ImageSource); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter FullyQualifiedName~WatermarkMapperTests +``` +Expected: PASS. + +- [ ] **Step 5: Regenerate the OpenAPI artifacts — build FIRST** + +Order matters: `update-openapi.sh` runs `dotnet-getdocument` against the already-built `ErsatzTV.dll`, so a stale assembly silently serializes the old schema (`docs/api-conventions.md:308-312`). + +```bash +dotnet build ErsatzTV.sln +./scripts/update-openapi.sh +cd web && npm run generate:api && cd .. +git diff --stat -- ErsatzTV/wwwroot/openapi/v1.json docs/endpoint-index.md web/src/api/generated/v1.d.ts +``` +Expected: `v1.d.ts` now shows `"WatermarkResponseModel": { "id": number; "name": string; "imageSource": components["schemas"]["ChannelWatermarkImageSource"]; }`. + +- [ ] **Step 6: Verify the generated artifacts are in sync** + +```bash +cd web && npm run check:api && cd .. +``` +Expected: exit 0, no diff. + +- [ ] **Step 7: Strip the BOM and commit** + +```bash +python3 - <<'EOF' +p = 'ErsatzTV.Application/Watermarks/Mapper.cs' +b = open(p, 'rb').read() +if b.startswith(b'\xef\xbb\xbf'): + open(p, 'wb').write(b[3:]) + print('stripped', p) +EOF +head -c3 ErsatzTV.Application/Watermarks/Mapper.cs | xxd -p # must NOT be efbbbf +git add -A +git commit -m "feat(67): add imageSource to the watermark picker DTO (additive)" +``` + +--- + +### Task 3: The shared `` component + +**Files:** +- Create: `web/src/components/bugPreview.tsx` +- Create: `web/src/components/bugPreview.test.tsx` +- Modify: `web/src/components/index.ts` (add the re-export — check the file's existing export style first and match it) + +**Interfaces:** +- Produces, consumed by Tasks 4 and 5: + ```ts + export interface BugPreviewGeometry { + location: components['schemas']['WatermarkLocation']; + size: components['schemas']['WatermarkSize']; + width: number; // percent of frame width, used when size === 'Scaled' + horizontalMargin: number; // percent + verticalMargin: number; // percent + opacity: number; // 0-100 + } + export function bugPreviewStyle(geometry: BugPreviewGeometry): CSSProperties; + export function BugPreview(props: BugPreviewGeometry & { src: string | null; alt?: string }): JSX.Element; + ``` + The geometry math lives in the pure `bugPreviewStyle` so it is unit-testable without the DOM. + +- [ ] **Step 1: Write the failing test** + +Create `web/src/components/bugPreview.test.tsx`: + +```tsx +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BugPreview, bugPreviewStyle, type BugPreviewGeometry } from './bugPreview'; + +const base: BugPreviewGeometry = { + horizontalMargin: 1, + location: 'TopLeft', + opacity: 80, + size: 'Scaled', + verticalMargin: 1, + width: 5 +}; + +describe('bugPreviewStyle', () => { + it('anchors TopLeft using the margins', () => { + const style = bugPreviewStyle(base); + expect(style.top).toBe('1%'); + expect(style.left).toBe('1%'); + expect(style.bottom).toBeUndefined(); + expect(style.right).toBeUndefined(); + }); + + it('anchors BottomRight using the margins', () => { + const style = bugPreviewStyle({ ...base, location: 'BottomRight' }); + expect(style.bottom).toBe('1%'); + expect(style.right).toBe('1%'); + expect(style.top).toBeUndefined(); + expect(style.left).toBeUndefined(); + }); + + it('centers horizontally for TopMiddle', () => { + const style = bugPreviewStyle({ ...base, location: 'TopMiddle' }); + expect(style.top).toBe('1%'); + expect(style.left).toBe('50%'); + expect(style.transform).toBe('translateX(-50%)'); + }); + + it('centers vertically for LeftMiddle', () => { + const style = bugPreviewStyle({ ...base, location: 'LeftMiddle' }); + expect(style.left).toBe('1%'); + expect(style.top).toBe('50%'); + expect(style.transform).toBe('translateY(-50%)'); + }); + + it('centers both axes for MiddleCenter', () => { + const style = bugPreviewStyle({ ...base, location: 'MiddleCenter' }); + expect(style.transform).toBe('translate(-50%, -50%)'); + }); + + it('scales width by percent when size is Scaled', () => { + expect(bugPreviewStyle({ ...base, width: 12 }).width).toBe('12%'); + }); + + it('leaves width unset at ActualSize', () => { + expect(bugPreviewStyle({ ...base, size: 'ActualSize' }).width).toBeUndefined(); + }); + + it('maps opacity percent to a 0-1 fraction', () => { + expect(bugPreviewStyle({ ...base, opacity: 80 }).opacity).toBeCloseTo(0.8); + }); +}); + +describe('BugPreview', () => { + afterEach(cleanup); + + it('renders the image when a src is supplied', () => { + render(); + expect(screen.getByAltText('Bug preview')).toHaveAttribute('src', '/iptv/logos/abc'); + }); + + it('renders an empty frame without a src', () => { + render(); + expect(screen.queryByRole('img')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd web && npx vitest run src/components/bugPreview.test.tsx +``` +Expected: FAIL — cannot resolve `./bugPreview`. + +- [ ] **Step 3: Implement the component** + +Create `web/src/components/bugPreview.tsx`: + +```tsx +import type { CSSProperties } from 'react'; +import type { components } from '../api/generated/v1'; + +// Renders where the on-screen bug lands, using the same percent-of-frame geometry the FFmpeg +// watermark filters use (WatermarkScaleFilter scales to widthPercent of output width; margins are +// percentages). This is an approximation for editing feedback, not a frame-accurate render. +export interface BugPreviewGeometry { + horizontalMargin: number; + location: components['schemas']['WatermarkLocation']; + opacity: number; + size: components['schemas']['WatermarkSize']; + verticalMargin: number; + width: number; +} + +export function bugPreviewStyle(geometry: BugPreviewGeometry): CSSProperties { + const horizontal = `${geometry.horizontalMargin}%`; + const vertical = `${geometry.verticalMargin}%`; + + const placement: CSSProperties = (() => { + switch (geometry.location) { + case 'BottomLeft': + return { bottom: vertical, left: horizontal }; + case 'BottomMiddle': + return { bottom: vertical, left: '50%', transform: 'translateX(-50%)' }; + case 'BottomRight': + return { bottom: vertical, right: horizontal }; + case 'LeftMiddle': + return { left: horizontal, top: '50%', transform: 'translateY(-50%)' }; + case 'MiddleCenter': + return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }; + case 'RightMiddle': + return { right: horizontal, top: '50%', transform: 'translateY(-50%)' }; + case 'TopLeft': + return { left: horizontal, top: vertical }; + case 'TopMiddle': + return { left: '50%', top: vertical, transform: 'translateX(-50%)' }; + case 'TopRight': + return { right: horizontal, top: vertical }; + default: + return { left: horizontal, top: vertical }; + } + })(); + + return { + ...placement, + objectFit: 'contain', + opacity: geometry.opacity / 100, + position: 'absolute', + width: geometry.size === 'Scaled' ? `${geometry.width}%` : undefined + }; +} + +export function BugPreview({ + alt = 'On-screen bug preview', + src, + ...geometry +}: BugPreviewGeometry & { alt?: string; src: null | string }) { + return ( +
+ {src && {alt}} +
+ ); +} +``` + +Then re-export it from `web/src/components/index.ts`, matching that file's existing export style (inspect it first — do not guess between `export *` and named re-exports). + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +cd web && npx vitest run src/components/bugPreview.test.tsx +``` +Expected: PASS, 10 tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/bugPreview.tsx web/src/components/bugPreview.test.tsx web/src/components/index.ts +git commit -m "feat(67): add shared BugPreview component with pure geometry helper" +``` + +--- + +### Task 4: Use `` on the Watermarks screen + +**Files:** +- Modify: `web/src/screens/WatermarksScreen.tsx:511-548` (the "Image" Row's raw `` block) +- Test: `web/src/screens/WatermarksScreen.test.tsx` (extend) + +**Interfaces:** +- Consumes: `BugPreview` from Task 3; the existing `Draft` (= `CreateWatermarkRequest`) fields `image`, `imageSource`, `location`, `size`, `width`, `horizontalMargin`, `verticalMargin`, `opacity`; `watermarkImageUrl` (already imported). + +- [ ] **Step 1: Write the failing test** + +Append to the `describe('WatermarksScreen', ...)` block in `web/src/screens/WatermarksScreen.test.tsx`: + +```tsx + it('renders a geometry preview of the watermark image in the editor', async () => { + window.history.pushState({}, '', '/app/watermarks/3'); + mockApi(); + render(); + + const preview = await screen.findByAltText('On-screen bug preview'); + expect(preview).toHaveAttribute('src', '/artwork/watermarks/logo.png'); + // BottomRight at 5% margins, 15% width, 100% opacity (see watermarkDetail fixture) + expect(preview).toHaveStyle({ bottom: '5%', right: '5%', width: '15%' }); + }); +``` + +If `/app/watermarks/3` is not the edit route, read the screen's routing block and use the real edit path — do not invent one. + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd web && npx vitest run src/screens/WatermarksScreen.test.tsx +``` +Expected: FAIL — `Unable to find an element with the alt text: On-screen bug preview`. + +- [ ] **Step 3: Replace the raw ``** + +Add `BugPreview` to the existing import from `'../components'` (line 5). Then replace exactly this block (`web/src/screens/WatermarksScreen.tsx`, inside the "Image" Row): + +```tsx + {custom && draft.image && ( + Watermark preview + )} +``` + +with: + +```tsx + {custom && draft.image && ( + + )} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +cd web && npx vitest run src/screens/WatermarksScreen.test.tsx +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/screens/WatermarksScreen.tsx web/src/screens/WatermarksScreen.test.tsx +git commit -m "feat(67): show resolved bug geometry on the watermarks screen" +``` + +--- + +### Task 5: The "Use logo as on-screen bug" toggle in the channel editor + +**Files:** +- Modify: `web/src/screens/ChannelEditScreen.tsx` — `BrandingPane` (`:646-760`) +- Test: `web/src/screens/ChannelEditScreen.test.tsx` (extend) + +**Interfaces:** +- Consumes: `BugPreview` (Task 3); `Watermark.imageSource` (Task 2); `getWatermark(id)` from `web/src/api/watermarks.ts`; existing `data.watermarks`, `draft.watermarkId`, `previewSrc`, `set()`. +- Produces, also used by Task 6 — add to `web/src/api/watermarks.ts` and export from the `'../api'` barrel: + ```ts + export function findLogoBugWatermark( + watermarks: T[] + ): T | null; + ``` + +**Behavior contract (read carefully — the first draft of this plan got this wrong):** +- **Ticked** iff the watermark that `draft.watermarkId` *points at* has `imageSource === 'ChannelLogo'`. Resolve it by **id lookup**, never by searching the list for the first `ChannelLogo` entry: `getWatermarks()` sorts by name (`web/src/api/pickers.ts:14`), so a search silently binds to the alphabetically-first logo-driven preset. Since the design explicitly invites users to create a second preset for per-channel geometry, that search would (a) show the toggle OFF for a channel that *is* logo-driven and (b) **repoint the channel** on tick — a data-changing action from a control documented as a pure reflection. +- **Tick-on target** is `findLogoBugWatermark(...)`: prefer the preset named exactly `Channel Bug`, else the first `ChannelLogo` preset, else `null`. With several logo-driven presets `imageSource` alone cannot identify *the* default, so the name is a deterministic tiebreak — not the identification mechanism. +- Loading and re-saving a channel without touching the toggle must leave `watermarkId` **byte-identical**, including when it points at a non-`ChannelLogo` watermark or at a `ChannelLogo` preset that is not the seeded one. +- The existing watermark `; // path -> ProblemDetails body returned with a 422 for a bulk mutation. mutationFailures?: Record; + watermarks?: unknown[] | null; + watermarksFail?: boolean; } interface MockHandle { @@ -65,6 +67,7 @@ function mockApi(options: MockOptions = {}): MockHandle { const channelStates = options.channelStates ?? []; const ffmpegSettings = options.ffmpegSettings ?? ffmpegSettingsFixture(); const mutationFailures = options.mutationFailures ?? {}; + const watermarks = options.watermarks ?? []; const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); @@ -81,6 +84,12 @@ function mockApi(options: MockOptions = {}): MockHandle { if (url === '/api/v1/channels' && method === 'GET') return Promise.resolve(jsonResponse(channels)); if (url === '/api/v1/channels/state' && method === 'GET') return Promise.resolve(jsonResponse(channelStates)); if (url === '/api/v1/settings/ffmpeg' && method === 'GET') return Promise.resolve(jsonResponse(ffmpegSettings)); + if (url === '/api/v1/watermarks' && method === 'GET') { + if (options.watermarksFail) { + return Promise.resolve(new Response(null, { status: 500 })); + } + return Promise.resolve(jsonResponse(watermarks)); + } if ( url === '/api/v1/channels/bulk/renumber' || @@ -564,4 +573,48 @@ describe('ChannelsScreen — bare create', () => { expect(window.location.pathname).toBe('/app/channels'); expect(screen.getByRole('button', { name: 'New blank channel' })).toBeInTheDocument(); }); + + // #67: one uploaded channel logo drives both the guide listing and the on-screen bug. A newly + // created channel should default to that shared preset rather than starting with no watermark. + it('defaults a newly created channel to the logo-bug watermark preset', async () => { + const handle = mockApi({ + channels: [channelRow()], + createChannelResponse: { id: 42 }, + watermarks: [{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }] + }); + + render(); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'New blank channel' })); + + await waitFor(() => + expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels', expect.objectContaining({ method: 'POST' })) + ); + + const body = requestBodyFor(handle, '/api/v1/channels'); + expect(body).toMatchObject({ watermarkId: 9 }); + }); + + // A watermark-list failure must degrade to no default, never block channel creation. + it('still creates the channel when the watermark lookup fails', async () => { + const handle = mockApi({ + channels: [channelRow()], + createChannelResponse: { id: 42 }, + watermarksFail: true + }); + + render(); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'New blank channel' })); + + await waitFor(() => + expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels', expect.objectContaining({ method: 'POST' })) + ); + + const body = requestBodyFor(handle, '/api/v1/channels'); + expect(body).toMatchObject({ watermarkId: null }); + await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/42')); + }); }); diff --git a/web/src/screens/ChannelsScreen.tsx b/web/src/screens/ChannelsScreen.tsx index d13bc84dd..618138708 100644 --- a/web/src/screens/ChannelsScreen.tsx +++ b/web/src/screens/ChannelsScreen.tsx @@ -17,7 +17,9 @@ import { bulkRenumberChannels, createChannel, deleteChannel, + findLogoBugWatermark, getFfmpegSettings, + getWatermarks, messageFromError, useChannelsScreenQuery, type ChannelState, @@ -347,6 +349,15 @@ export function ChannelsScreen() { return Number.isFinite(parsed) ? Math.max(max, parsed) : max; }, 0); + // New channels default to the seeded logo-driven preset so one uploaded logo drives both the + // guide listing and the on-screen bug (#67). A lookup failure must not block channel creation. + let watermarkId: null | number = null; + try { + watermarkId = findLogoBugWatermark(await getWatermarks())?.id ?? null; + } catch { + watermarkId = null; + } + const body: CreateChannelRequest = { categories: null, fallbackFillerId: null, @@ -374,7 +385,7 @@ export function ChannelsScreen() { streamSelectorMode: 'Default', subtitleMode: 'None', transcodeMode: 'OnDemand', - watermarkId: null + watermarkId }; const created = await createChannel(body); diff --git a/web/src/screens/WatermarksScreen.test.tsx b/web/src/screens/WatermarksScreen.test.tsx index 13bfbbdff..7fb3a1d80 100644 --- a/web/src/screens/WatermarksScreen.test.tsx +++ b/web/src/screens/WatermarksScreen.test.tsx @@ -95,4 +95,15 @@ describe('WatermarksScreen', () => { await waitFor(() => expect(screen.getByDisplayValue('Logo (copy)')).toBeInTheDocument()); expect(screen.getByText('New watermark')).toBeInTheDocument(); }); + + it('renders a geometry preview of the watermark image in the editor', async () => { + window.history.pushState({}, '', '/app/watermarks/3'); + mockApi(); + render(); + + const preview = await screen.findByAltText('On-screen bug preview'); + expect(preview).toHaveAttribute('src', '/artwork/watermarks/logo.png'); + // BottomRight at 5% margins, 15% width, 100% opacity (see watermarkDetail fixture) + expect(preview).toHaveStyle({ bottom: '5%', right: '5%', width: '15%' }); + }); }); diff --git a/web/src/screens/WatermarksScreen.tsx b/web/src/screens/WatermarksScreen.tsx index 1fa6acd72..c12037d6b 100644 --- a/web/src/screens/WatermarksScreen.tsx +++ b/web/src/screens/WatermarksScreen.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import { ArrowLeft, Check, Copy, Image as ImageIcon, Plus, Trash2, TriangleAlert, Upload } from 'lucide-react'; import { navigateToPath } from '../routing'; import { usePrimaryAction } from '../primaryAction'; -import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components'; +import { Badge, BugPreview, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components'; import { createWatermark, deleteWatermark, @@ -532,10 +532,14 @@ function WatermarkEditor({ mode }: { mode: { id: number; kind: 'edit' } | { from Upload image {custom && draft.image && ( - Watermark preview )} {uploadError && (