feat(67): one logo drives both the listing and the on-screen bug #504

Merged
timothy merged 20 commits from feat/67-unified-logo-bug into main 2026-07-20 21:36:45 +02:00
27 changed files with 2405 additions and 29 deletions
+2 -2
View File
@@ -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(
@@ -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);
+1
View File
@@ -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");
+70 -7
View File
@@ -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<int?> 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<FFmpegProfile> 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
};
}
@@ -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,
@@ -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);
}
}
@@ -63,8 +63,8 @@ public class WatermarkControllerTests
{
List<WatermarkResponseModel> 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<GetAllWatermarksForApi>(), Arg.Any<CancellationToken>())
.Returns(models);
@@ -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<ChannelTemplate> 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);
}
}
}
+5 -1
View File
@@ -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"
}
}
},
+4
View File
@@ -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
+31 -2
View File
@@ -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.
+40
View File
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
# Unified logo / on-screen bug — design (ersatztv#67)
Date: 2026-07-20
Issue: [#67](http://192.168.1.95:3000/timothy/ersatztv/issues/67) (child of the #62 "Create Channel
backend prerequisites" epic)
Status: design approved, ready for implementation planning
## 1. What the issue asked for vs. what already exists
#67 asks that a single uploaded image drive **both** the listing logo and the on-screen bug
(watermark) by default, with the option to set them separately, plus live preview for both uses.
Recon showed most of that capability is already built:
| #67 requirement | State before this work |
|---|---|
| One image drives logo + bug | **Exists.** `ChannelWatermarkImageSource.ChannelLogo` (`ErsatzTV.Core/Domain/ChannelWatermark.cs:48`) resolves the channel's `ArtworkKind.Logo` artwork at render time, at all three watermark precedence levels (`ErsatzTV.Core/FFmpeg/WatermarkSelector.cs:216-236,265-285,314-334,384-392`). Exposed as `WatermarkFullResponseModel.ImageSource`, selectable in `web/src/screens/WatermarksScreen.tsx:30-33`. |
| Override either independently | **Exists.** `ImageSource = Custom` + a watermark-target upload. |
| ...by **default** | **Missing.** New channels are created with `watermarkId: null` (`web/src/screens/ChannelsScreen.tsx:377`); nothing is logo-driven unless a watermark is hand-built and hand-assigned. |
| Live preview for both uses | **Missing.** `WatermarksScreen.tsx:534-539` renders only a raw `<img>` of the watermark file. Nothing shows the resolved geometry (location / size / margins / opacity), which is the part users actually get wrong. |
**Field validation.** The production instance (jazz, 192.168.1.29) already runs this pattern by hand:
all 43 channels carry `ArtworkKind.Logo` artwork and point at a single shared watermark named
`Channel Bug` with `ImageSource = ChannelLogo` and `Image = NULL`. This design therefore does not
invent a mechanism — it productizes the one the operator already proved, so that a new channel gets
it without hand-wiring, and so the geometry is visible before saving.
Observed prod geometry, adopted as the shipped default:
```
Name="Channel Bug" Mode=Permanent(1) ImageSource=ChannelLogo(1) Image=NULL
Location=TopLeft(3) Size=Scaled(0) WidthPercent=5.0
HorizontalMarginPercent=1.0 VerticalMarginPercent=1.0
FrequencyMinutes=0 DurationSeconds=0 Opacity=80
PlaceWithinSourceContent=0 ZIndex=0
```
## 2. Structural constraints that shaped the design
1. **A watermark is a shared, named entity**, not per-channel state: unique `Name`
(`ErsatzTV.Infrastructure/Data/Configurations/ChannelWatermarkConfiguration.cs:11-18`), referenced
by playout items, schedule items, block items and decos (`ChannelWatermark.cs:24-31`), with
`Channel.WatermarkId` pointing at one (`Channel.cs:21-22`). Per-channel bug *settings* have
nowhere to live without either new `Channel` columns or one watermark row per channel.
2. **`ImageSource = ChannelLogo` resolves per-channel at render time.** One shared preset therefore
already makes every channel use *its own* logo. This is why no schema change is needed.
3. **Logo is not a `Channel` field** — it is an `Artwork` row (`ArtworkKind.Logo = 2`) whose `Path`
holds an image-cache hash or an absolute external URL, served at `/iptv/logos/{hash}`
(`ErsatzTV/Controllers/IptvController.cs:282-293`).
4. **`/api/v1` is frozen-additive** (`docs/decisions.md`, #286): fields may be added, never removed
or reshaped.
### Alternatives rejected
- **Per-channel bug columns on `Channel`** — full fidelity to "separably overridable", but requires a
dual-provider migration (`scripts/add-migration.sh`) and changes to the watermark precedence chain.
Rejected as disproportionate: the shared preset already delivers the user-visible behavior.
- **Auto-create a watermark per channel** — gives per-channel geometry with no migration, but
proliferates rows under a unique-name index and fills the shared Watermarks screen with one row per
channel.
Accepted trade-off: every channel on the shared preset shares one geometry. Per-channel tweaks mean
creating a second preset — which the existing Watermarks screen already supports.
## 3. Backend
`DbInitializer.SeedChannelBugWatermark(context, cancellationToken)`, invoked from `Initialize`
**before** the existing `SeedChannelTemplates` call (`ErsatzTV.Infrastructure/Data/DbInitializer.cs:134`)
so the seeded watermark's id is available to the template seed. It returns `int?` — the preset's id.
- If a `ChannelWatermark` named `Channel Bug` already exists → **adopt it untouched**, never
duplicated and never overwritten. An operator's tuned geometry (as on prod) survives.
- Otherwise insert one row with the geometry in §1.
- **Seed-once marker.** `ChannelWatermark` has no `IsSystem` flag (unlike `ChannelTemplate`), so a
pure name-guard would resurrect the row on every restart after a deliberate delete or rename. A
`ConfigElement` marker (`ConfigElementKey.WatermarkChannelBugSeeded`, key
`watermark.channel_bug_seeded`) is written on first seed **or adopt**; once present, the seed is a
no-op. This follows the file's own `EnsureDefaultChannelTemplateConfig` precedent (`:210-231`).
A deliberate delete stays deleted.
`SeedChannelTemplates` takes the returned id and stamps it as `WatermarkId` on the **newly seeded**
system templates (`ChannelTemplate.WatermarkId`, `ErsatzTV.Core/Domain/ChannelTemplate.cs:14`). This
only ever touches rows the seed is creating for the first time — the existing name-guard means an
install that already has `Standard` / `Music videos` is untouched, so no operator's template is
mutated.
No schema change. No `WatermarkSelector` change. No change to how watermarks render.
**Additive DTO change**: `WatermarkResponseModel` (the `GET /api/v1/watermarks` picker list) gains
`imageSource`, so the SPA can identify logo-driven presets generically instead of matching a
user-editable name. Legal under frozen-additive `/api/v1`; also useful to MCP/machine clients.
## 4. Frontend
- **`<BugPreview>`** — new shared presentational component: a 16:9 frame with the image absolutely
positioned from `location` (9 values, `ErsatzTV.FFmpeg/State/WatermarkState.cs`), `size`,
`widthPercent`, horizontal/vertical margin percents and `opacity`. No data fetching.
- **Where the default is applied — at creation, not by inferring "newness" in the editor.** Quick-add
creates the channel through the API and *then* navigates to `/app/edit-channel/{id}`
(`docs/decisions.md:445-456`), so the editor only ever loads an already-persisted row and cannot
reliably tell "new" from "existing". The default is therefore applied at the SPA's quick-add
creation path — `ChannelsScreen.tsx:377`, which currently hardcodes `watermarkId: null` — by
setting `watermarkId` to the seeded preset at create time.
- **The `ChannelBuilder` lineup flow is covered via template stamping** (revised after independent
review). It does not hardcode a watermark: it *inherits* `watermarkId` from the selected
`ChannelTemplate` (`web/src/builder/ChannelBuilder.tsx:1025`, `:2046`). Because §3's seed stamps the
preset onto templates **it is creating for the first time**, a fresh install gets the default through
both creation flows with no user data mutated. On an existing install (including production) the
template name-guard means nothing is stamped, so builder-created channels there keep inheriting
whatever the template already says — a user who wants it sets the watermark on the template.
`POST /api/v1/channels/auto-tune` is unchanged either way.
- **`ChannelEditScreen`** (Branding tab, `:55`): a **"Use logo as on-screen bug"** toggle. Loading and
re-saving an existing channel never changes its watermark.
- **Reflect from the referenced row, not from a search.** Ticked iff the watermark
`draft.watermarkId` *points at* has `imageSource === 'ChannelLogo'`. An earlier draft searched the
list for the first `ChannelLogo` preset; because `getWatermarks()` sorts by name
(`web/src/api/pickers.ts:14`), that silently repointed a channel to the alphabetically-first
logo-driven preset the moment a second one existed — which this design's own trade-off invites
users to create. Caught in independent review.
- **Tick-on target is deterministic**: prefer the preset named `Channel Bug`, else the first
`ChannelLogo` preset. With multiple logo-driven presets, `imageSource` alone cannot identify *the*
default, so a name preference is unavoidable here; it is a tiebreak, not the identification
mechanism.
- **The preview shows the referenced preset's real geometry**, fetched via the existing
`GET /api/v1/watermarks/{id}` when the toggle is on. Hardcoding the seeded defaults would show
the wrong position and size for exactly the users who tuned theirs — the case this feature exists
to serve.
- **External-URL logos get no bug preview.** `WatermarkSelector` resolves an external-URL logo to
the URL itself and then calls `File.Exists` on it (`ErsatzTV.Core/FFmpeg/WatermarkSelector.cs:269-286`),
which is never true, so the bug is silently dropped. The preview must not promise a bug that will
never render; the toggle's help text and `docs/channels.md` say so. The underlying defect is
pre-existing and tracked in its own issue, not fixed here.
- **Batch auto-tune (`POST /api/v1/channels/auto-tune`) is left alone** — it is a server-side create,
and the rejected "server-side default on create" option is what would have changed it.
- **`WatermarksScreen`**: the raw `<img>` at `:534-539` is replaced by `<BugPreview>`.
Existing logo-upload and external-URL-precedence behavior (`docs/decisions.md:457-466`) is unchanged.
## 5. Testing
- **NUnit**, mirroring `ErsatzTV.Tests/Infrastructure/DbInitializerChannelTemplateTests.cs`:
seeds the preset when absent; **adopts an existing same-name row without duplicating or mutating
it** (the production case — this test is what protects the operator's tuned geometry); is
idempotent across repeated `Initialize` calls; **does not resurrect a deleted preset** once the
seed marker exists; stamps `WatermarkId` on freshly seeded templates only.
- **Vitest**: `BugPreview` geometry mapping across all 9 locations, plus opacity/size/margins. For the
toggle:
- **loading and re-saving an existing channel without touching the toggle leaves `watermarkId`
byte-identical** — the guarantee protecting the 43 production channels, and the test the first
draft of this plan promised but omitted;
- with **two** `ChannelLogo` presets, a channel pointing at the non-first one still reads as ticked
and is not repointed (the review's blocker, pinned by a regression test);
- the creation path stamps the preset, and **degrades to `null` rather than aborting** when the
watermark lookup fails.
## 6. Docs to update in the same PR
Per `CLAUDE.md`'s docs-update rule:
- `docs/api-conventions.md` checklist + regenerate `v1.json` and `endpoint-index.md` via
`./scripts/update-openapi.sh`, then `npm run generate:api` (the `imageSource` addition).
- `docs/channels.md` — watermark section: the seeded `Channel Bug` preset and the logo-drives-bug default.
- `docs/decisions.md` — the shared-preset-over-migration decision and its accepted trade-off.
- `docs/spa-conventions.md` — only if `<BugPreview>` establishes a new shared-component convention.
## 7. Out of scope
- Any dual-provider migration.
- **Fixing the external-URL-logo defect.** `WatermarkSelector.cs:269-286` resolves an external-URL
logo to the URL and then `File.Exists`-checks it, so such channels never render a bug even though
the editor advertises the URL as winning. Pre-existing, unrelated to this change, and a fix lands in
the FFmpeg render path — a different risk class than this seed-plus-SPA PR. Tracked as **#502**;
this PR only stops the preview from promising it.
- The dead generated-initials FFmpeg fallback: `WatermarkSelector.cs:230-236` runs `File.Exists`
against a URL produced by `ChannelLogoGenerator.GenerateChannelLogoUrl`, which hardcodes localhost
(`ChannelLogoGenerator.cs:93-94`, issue #1). Deliberately deferred in `docs/decisions.md:1617-1623`.
- The initials shown at `/app/channels` on production: **not a defect.** That is fixed on `main` by
`1aa1f0cf` ("fix(464): render real channel logos in guide grid + channels list", 2026-07-19 13:28),
which postdates the deployed image (built 2026-07-18 22:38, i.e. tag `v26.10.0`). It resolves on
the pending v26.11.0 deploy — the same deploy #350 and #474 are waiting on.
+1
View File
@@ -1872,6 +1872,7 @@ export interface components {
"WatermarkResponseModel": {
"id": number;
"name": string;
"imageSource": components["schemas"]["ChannelWatermarkImageSource"];
};
"WatermarkSize": "Scaled" | "ActualSize";
"XmltvBlockBehavior": "SplitTimeEvenly" | "UseActualTimes";
+31
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createWatermark,
deleteWatermark,
findLogoBugWatermark,
getWatermark,
updateWatermark,
watermarkImageUrl,
@@ -84,3 +85,33 @@ describe('watermarks api client', () => {
expect(watermarkImageUrl('abc.png')).toBe('/artwork/watermarks/abc.png');
});
});
describe('findLogoBugWatermark', () => {
it('prefers the row named "Channel Bug" over an earlier ChannelLogo row', () => {
const watermarks = [
{ id: 4, imageSource: 'ChannelLogo' as const, name: 'Alpha Bug' },
{ id: 9, imageSource: 'ChannelLogo' as const, name: 'Channel Bug' }
];
expect(findLogoBugWatermark(watermarks)).toMatchObject({ id: 9 });
});
it('falls back to the first ChannelLogo row when no "Channel Bug" exists', () => {
const watermarks = [
{ id: 2, imageSource: 'Custom' as const, name: 'Corner bug' },
{ id: 4, imageSource: 'ChannelLogo' as const, name: 'Alpha Bug' },
{ id: 6, imageSource: 'ChannelLogo' as const, name: 'Beta Bug' }
];
expect(findLogoBugWatermark(watermarks)).toMatchObject({ id: 4 });
});
it('returns null when none are ChannelLogo', () => {
const watermarks = [
{ id: 2, imageSource: 'Custom' as const, name: 'Corner bug' },
{ id: 3, imageSource: 'Resource' as const, name: 'Station ident' }
];
expect(findLogoBugWatermark(watermarks)).toBeNull();
});
});
+10
View File
@@ -13,6 +13,16 @@ export function getWatermark(id: number): Promise<WatermarkDetail> {
return request<WatermarkDetail>(`/api/v1/watermarks/${id}`);
}
// Identifies THE default logo-driven preset. imageSource alone is not enough once a second
// ChannelLogo preset exists (the design invites creating one for per-channel geometry), and
// getWatermarks() sorts by name — so prefer the seeded name, then fall back deterministically.
export function findLogoBugWatermark<
T extends { id: number; imageSource: components['schemas']['ChannelWatermarkImageSource']; name: null | string }
>(watermarks: T[]): T | null {
const logoDriven = watermarks.filter((watermark) => watermark.imageSource === 'ChannelLogo');
return logoDriven.find((watermark) => watermark.name === 'Channel Bug') ?? logoDriven[0] ?? null;
}
export function createWatermark(body: CreateWatermarkRequest): Promise<WatermarkDetail> {
return request<WatermarkDetail>('/api/v1/watermarks', { body, method: 'POST' });
}
+107
View File
@@ -0,0 +1,107 @@
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('anchors BottomLeft using the margins', () => {
const style = bugPreviewStyle({ ...base, location: 'BottomLeft' });
expect(style.bottom).toBe('1%');
expect(style.left).toBe('1%');
expect(style.top).toBeUndefined();
expect(style.right).toBeUndefined();
expect(style.transform).toBeUndefined();
});
it('anchors TopRight using the margins', () => {
const style = bugPreviewStyle({ ...base, location: 'TopRight' });
expect(style.top).toBe('1%');
expect(style.right).toBe('1%');
expect(style.bottom).toBeUndefined();
expect(style.left).toBeUndefined();
expect(style.transform).toBeUndefined();
});
it('centers vertically for RightMiddle', () => {
const style = bugPreviewStyle({ ...base, location: 'RightMiddle' });
expect(style.right).toBe('1%');
expect(style.top).toBe('50%');
expect(style.transform).toBe('translateY(-50%)');
});
it('centers horizontally for BottomMiddle', () => {
const style = bugPreviewStyle({ ...base, location: 'BottomMiddle' });
expect(style.bottom).toBe('1%');
expect(style.left).toBe('50%');
expect(style.transform).toBe('translateX(-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(<BugPreview {...base} alt="Bug preview" src="/iptv/logos/abc" />);
expect(screen.getByAltText('Bug preview')).toHaveAttribute('src', '/iptv/logos/abc');
});
it('renders an empty frame without a src', () => {
render(<BugPreview {...base} src={null} />);
expect(screen.queryByRole('img')).toBeNull();
});
});
+79
View File
@@ -0,0 +1,79 @@
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.
/* eslint-disable react-refresh/only-export-components -- geometry math must stay colocated and pure for unit testing without the DOM */
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%)' };
// NOTE: FFmpeg currently renders MiddleCenter bottom-right (OverlayWatermarkFilter has no
// arm for it, so it hits the bottom-right default) — see #503. We keep the semantically
// correct centered preview here rather than encoding that defect.
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 (
<div
style={{
aspectRatio: '16 / 9',
background: 'var(--ctv-surface-sunken, #1b1b1f)',
border: '1px solid var(--ctv-border, #33343a)',
borderRadius: 6,
maxWidth: 320,
overflow: 'hidden',
position: 'relative',
width: '100%'
}}
>
{src && <img alt={alt} src={src} style={bugPreviewStyle(geometry)} />}
</div>
);
}
+6 -1
View File
@@ -232,6 +232,10 @@ export interface SwitchProps {
checked?: boolean;
onChange?: (next: boolean) => void;
label?: string;
// When true, `label` still becomes the accessible name (aria-label) but is not rendered as
// visible text — for callers that already show the same caption elsewhere (e.g. a settings
// Row label) and would otherwise duplicate it.
hideLabel?: boolean;
disabled?: boolean;
size?: ControlSize;
style?: CSSProperties;
@@ -241,6 +245,7 @@ export function Switch({
checked = false,
onChange,
label,
hideLabel = false,
disabled = false,
size = 'md',
style
@@ -269,7 +274,7 @@ export function Switch({
>
<span className="ctv-switch-knob" />
</button>
{label && <span>{label}</span>}
{label && !hideLabel && <span>{label}</span>}
</span>
);
}
+1
View File
@@ -1,5 +1,6 @@
import './components.css';
export * from './bugPreview';
export * from './dataDisplay';
export * from './feedback';
export * from './forms';
+243 -2
View File
@@ -54,9 +54,16 @@ interface FetchOptions {
channelStatus?: number;
onPut?: (body: unknown) => void;
putResponseOverrides?: Record<string, unknown>;
watermarks?: Record<string, unknown>[];
}
function mockApi({ channelOverrides = {}, channelStatus = 200, onPut, putResponseOverrides = {} }: FetchOptions = {}) {
function mockApi({
channelOverrides = {},
channelStatus = 200,
onPut,
putResponseOverrides = {},
watermarks = [{ id: 2, name: 'Corner bug', imageSource: 'Custom' }]
}: FetchOptions = {}) {
const loadedChannel = { ...channel, ...channelOverrides };
return vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
@@ -79,7 +86,25 @@ function mockApi({ channelOverrides = {}, channelStatus = 200, onPut, putRespons
}
if (url === '/api/v1/watermarks') {
return Promise.resolve(json([{ id: 2, name: 'Corner bug' }]));
return Promise.resolve(json(watermarks));
}
if (url.startsWith('/api/v1/watermarks/')) {
const id = Number(url.slice('/api/v1/watermarks/'.length));
const found = watermarks.find((watermark) => watermark.id === id);
return found
? Promise.resolve(
json({
horizontalMargin: 1,
location: 'TopLeft',
opacity: 80,
size: 'Scaled',
verticalMargin: 1,
width: 5,
...found
})
)
: Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
}
if (url === '/api/v1/filler-presets') {
@@ -335,4 +360,220 @@ describe('ChannelEditScreen', () => {
expect(await screen.findByDisplayValue('legacy-selector.py')).toBeInTheDocument();
});
});
describe('use logo as on-screen bug toggle', () => {
it('ticks the logo-bug toggle when the channel uses a ChannelLogo watermark', async () => {
mockApi({
channelOverrides: { watermarkId: 9 },
watermarks: [
{ id: 2, imageSource: 'Custom', name: 'Corner bug' },
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
]
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
expect(await screen.findByRole('switch', { name: 'Use logo as on-screen bug' })).toHaveAttribute(
'aria-checked',
'true'
);
});
it('clears watermarkId when the logo-bug toggle is switched off', async () => {
const puts: unknown[] = [];
mockApi({
channelOverrides: { watermarkId: 9 },
onPut: (body) => puts.push(body),
watermarks: [
{ id: 2, imageSource: 'Custom', name: 'Corner bug' },
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
]
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
fireEvent.click(await screen.findByRole('switch', { name: 'Use logo as on-screen bug' }));
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect((puts[0] as { watermarkId: null | number }).watermarkId).toBeNull();
});
// Regression: a search-by-imageSource implementation binds to the alphabetically-first
// ChannelLogo preset (getWatermarks sorts by name), silently repointing this channel.
it('reflects a non-first ChannelLogo preset without repointing the channel', async () => {
const puts: unknown[] = [];
mockApi({
channelOverrides: { watermarkId: 9 },
onPut: (body) => puts.push(body),
// 'Alpha Bug' sorts before 'Channel Bug', so a search-by-imageSource would bind to id 4.
watermarks: [
{ id: 4, imageSource: 'ChannelLogo', name: 'Alpha Bug' },
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
]
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
expect(await screen.findByRole('switch', { name: 'Use logo as on-screen bug' })).toHaveAttribute(
'aria-checked',
'true'
);
// Change something unrelated so the form is dirty and Save is enabled — the toggle itself
// must NOT have touched watermarkId.
fireEvent.click(screen.getByRole('button', { name: /^General/ }));
fireEvent.change(screen.getByDisplayValue('Cartoons'), { target: { value: 'Renamed channel' } });
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect((puts[0] as { watermarkId: null | number }).watermarkId).toBe(9);
});
// The guarantee protecting the existing production channels.
it('leaves watermarkId unchanged when an untouched channel is re-saved', async () => {
const puts: unknown[] = [];
mockApi({
channelOverrides: { watermarkId: 2 },
onPut: (body) => puts.push(body),
watermarks: [
{ id: 2, imageSource: 'Custom', name: 'Sponsor bug' },
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
]
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
expect(screen.getByRole('switch', { name: 'Use logo as on-screen bug' })).toHaveAttribute(
'aria-checked',
'false'
);
// Change something unrelated so the form is dirty and Save is enabled.
fireEvent.click(screen.getByRole('button', { name: /^General/ }));
fireEvent.change(screen.getByDisplayValue('Cartoons'), { target: { value: 'Renamed channel' } });
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect((puts[0] as { watermarkId: null | number }).watermarkId).toBe(2);
});
// Regression: toggling off (watermarkId -> null) then back on used to always rebind to
// logoBugTarget (the 'Channel Bug' default), silently losing a channel's own per-geometry
// ChannelLogo preset.
it('restores the channel’s own ChannelLogo preset — not the default — when re-toggled on', async () => {
const puts: unknown[] = [];
mockApi({
channelOverrides: { watermarkId: 4 },
onPut: (body) => puts.push(body),
watermarks: [
{ id: 4, imageSource: 'ChannelLogo', name: 'Alpha Bug' },
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
]
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
const toggle = await screen.findByRole('switch', { name: 'Use logo as on-screen bug' });
expect(toggle).toHaveAttribute('aria-checked', 'true');
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-checked', 'false');
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-checked', 'true');
// watermarkId round-trips back to its original value across the off/on toggle, so the form
// isn't dirty from that alone — touch something unrelated to enable Save.
fireEvent.click(screen.getByRole('button', { name: /^General/ }));
fireEvent.change(screen.getByDisplayValue('Cartoons'), { target: { value: 'Renamed channel' } });
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect((puts[0] as { watermarkId: null | number }).watermarkId).toBe(4);
});
});
describe('on-screen bug preview', () => {
it('renders the preview image using the fetched watermark geometry', async () => {
mockApi({
channelOverrides: {
logo: { path: 'iptv/logos/cartoons.png', contentType: 'image/png' },
watermarkId: 9
},
watermarks: [
{ id: 2, imageSource: 'Custom', name: 'Corner bug' },
{
id: 9,
imageSource: 'ChannelLogo',
name: 'Channel Bug',
horizontalMargin: 3,
location: 'BottomLeft',
opacity: 60,
size: 'Scaled',
verticalMargin: 7,
width: 11
}
]
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
const preview = await screen.findByAltText('On-screen bug preview');
// Asymmetric values so a horizontal/vertical (or margin/width) prop swap fails this test.
expect(preview).toHaveAttribute('src', 'iptv/logos/cartoons.png');
expect(preview).toHaveStyle({
bottom: '7%',
left: '3%',
opacity: '0.6',
width: '11%'
});
expect(preview.style.right).toBe('');
expect(preview.style.top).toBe('');
});
it('does not render the preview when an external logo URL is set', async () => {
mockApi({
channelOverrides: {
logo: { path: 'iptv/logos/cartoons.png', contentType: 'image/png' },
watermarkId: 9
},
watermarks: [{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }]
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
// Preview renders from the uploaded logo first.
await screen.findByAltText('On-screen bug preview');
const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } });
// An external URL can never resolve to a real bug (see the comment on the WatermarkSelector
// File.Exists behavior), so the preview must disappear once one is set.
await waitFor(() => expect(screen.queryByAltText('On-screen bug preview')).toBeNull());
});
});
});
+108 -1
View File
@@ -12,9 +12,10 @@ import {
Upload
} from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, ChannelLogo, Input, Select, Switch } from '../components';
import { Badge, BugPreview, Button, Card, ChannelLogo, Input, Select, Switch, type BugPreviewGeometry } from '../components';
import {
ApiError,
findLogoBugWatermark,
getChannelById,
getChannels,
getChannelStreamSelectors,
@@ -22,6 +23,7 @@ import {
getFillerPresets,
getLanguages,
getMusicVideoCreditsTemplates,
getWatermark,
getWatermarks,
messageFromError,
updateChannel,
@@ -672,6 +674,73 @@ function BrandingPane({
const trimmedUrl = externalLogoUrl.trim();
const previewSrc = trimmedUrl ? externalLogoUrl : (logoPreview ?? existingLogo);
// Reflect the referenced row — never a search — so a channel bound to a second ChannelLogo
// preset reads correctly and is never silently repointed.
const referenced = data.watermarks.find((watermark) => watermark.id === draft.watermarkId) ?? null;
const logoBugEnabled = referenced?.imageSource === 'ChannelLogo';
const logoBugTarget = findLogoBugWatermark(data.watermarks);
// An external-URL logo is resolved by WatermarkSelector to the URL itself and then File.Exists-ed,
// which is never true, so no bug renders (#502). Don't promise one in the preview.
const externalUrlLogo = trimmedUrl.length > 0;
// Keyed by the watermark id it was fetched for, so a stale response (or a disabled toggle) is
// filtered out by comparing against the CURRENT draft.watermarkId at render time — no reset
// branch is needed inside the effect (avoids a synchronous setState-in-effect).
const [fetchedGeometry, setFetchedGeometry] = useState<null | ({
horizontalMargin: number;
id: number;
location: BugPreviewGeometry['location'];
opacity: number;
size: BugPreviewGeometry['size'];
verticalMargin: number;
width: number;
})>(null);
useEffect(() => {
if (!logoBugEnabled || draft.watermarkId == null) {
return;
}
let cancelled = false;
const watermarkId = draft.watermarkId;
void getWatermark(watermarkId)
.then((watermark) => {
if (!cancelled) {
setFetchedGeometry({
horizontalMargin: watermark.horizontalMargin,
id: watermarkId,
location: watermark.location,
opacity: watermark.opacity,
size: watermark.size,
verticalMargin: watermark.verticalMargin,
width: watermark.width
});
}
})
.catch(() => {
// Leave any prior geometry as-is; the id-match check below keeps stale data from a
// different watermark from being shown.
});
return () => {
cancelled = true;
};
}, [draft.watermarkId, logoBugEnabled]);
const bugGeometry = fetchedGeometry && fetchedGeometry.id === draft.watermarkId ? fetchedGeometry : null;
// Remembers the last logo-driven watermark this channel actually referenced — which may be a
// per-channel-geometry ChannelLogo preset other than logoBugTarget (the shared default). Without
// this, toggling off (watermarkId -> null) then back on would always rebind to logoBugTarget,
// silently losing a custom preset's geometry. Only updated while the toggle reads as ON, so a
// toggle-off never clobbers the memory of what to restore.
const lastLogoWatermarkIdRef = useRef<number | null>(null);
useEffect(() => {
if (logoBugEnabled && draft.watermarkId != null) {
lastLogoWatermarkIdRef.current = draft.watermarkId;
}
}, [logoBugEnabled, draft.watermarkId]);
return (
<Pane subtitle="Channel logo and the overlays applied while streaming." title="Branding">
<Card padded={false}>
@@ -723,6 +792,44 @@ function BrandingPane({
value={externalLogoUrl}
/>
</Row>
<Row
control={340}
help={
logoBugTarget == null
? 'No logo-driven watermark preset exists yet.'
: externalUrlLogo
? 'An external logo URL cannot be used as the on-screen bug — upload an image instead.'
: 'Overlays this channel’s own logo on the stream, using the shared preset’s position and size.'
}
label="Use logo as on-screen bug"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Switch
checked={logoBugEnabled}
disabled={hlsDirect || logoBugTarget == null}
hideLabel
label="Use logo as on-screen bug"
onChange={(next) =>
set({ watermarkId: next ? (lastLogoWatermarkIdRef.current ?? logoBugTarget?.id ?? null) : null })
}
size="sm"
/>
{logoBugEnabled && bugGeometry && previewSrc && !externalUrlLogo && (
// bugGeometry is cached keyed by `id` (see fetchedGeometry above); pass the
// geometry fields explicitly rather than spreading so the cache key never leaks
// into BugPreview's props.
<BugPreview
horizontalMargin={bugGeometry.horizontalMargin}
location={bugGeometry.location}
opacity={bugGeometry.opacity}
size={bugGeometry.size}
src={previewSrc}
verticalMargin={bugGeometry.verticalMargin}
width={bugGeometry.width}
/>
)}
</div>
</Row>
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Overlay applied to the channel.'} label="Watermark">
<Select
disabled={hlsDirect}
+53
View File
@@ -52,6 +52,8 @@ interface MockOptions {
ffmpegSettings?: Record<string, unknown>;
// path -> ProblemDetails body returned with a 422 for a bulk mutation.
mutationFailures?: Record<string, { detail?: string; status?: number; title?: string }>;
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(<ChannelsScreen />);
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(<ChannelsScreen />);
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'));
});
});
+12 -1
View File
@@ -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);
+11
View File
@@ -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(<WatermarksScreen />);
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%' });
});
});
+8 -4
View File
@@ -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
</Button>
{custom && draft.image && (
<img
alt="Watermark preview"
<BugPreview
horizontalMargin={draft.horizontalMargin}
location={draft.location}
opacity={draft.opacity}
size={draft.size}
src={watermarkImageUrl(draft.image)}
style={{ maxHeight: 60, maxWidth: 200, objectFit: 'contain' }}
verticalMargin={draft.verticalMargin}
width={draft.width}
/>
)}
{uploadError && (