From 1aa1f0cf5b77dd0006d5b773e67510015bfa4f8c Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 19 Jul 2026 13:28:00 +0200 Subject: [PATCH] fix(464): render real channel logos in guide grid + channels list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide/EPG grid (/app/guide) and the channels list (/app/channels) always drew the generated initials "bug" because the browse DTOs never carried a logo URL — GuideScreen/ChannelsScreen rendered with no src. The logo data existed (it round-trips through the channel editor) but never reached these views. Add a rooted, directly-usable Logo URL to ChannelGuideChannelResponseModel and ChannelResponseModel, populated by a single Channels.Mapper.GetLogoUrl helper (#181 artwork convention): /iptv/logos/{file} for an uploaded logo, the absolute URL passed through for an external one, null when unset so the SPA keeps its generated-initials fallback. The guide query now includes Channel.Artwork. Regenerated OpenAPI + v1.d.ts; updated api-conventions.md + domain-model.md. fixes #464 Co-Authored-By: Claude Opus 4.8 (1M context) --- ErsatzTV.Application/Channels/Mapper.cs | 28 ++++++++++- .../Queries/GetChannelGuideDataHandler.cs | 2 + .../Api/Channels/ChannelGuideResponseModel.cs | 3 ++ .../Api/Channels/ChannelResponseModel.cs | 5 +- .../GetAllChannelsForApiHandlerTests.cs | 48 +++++++++++++++++++ .../GetChannelGuideDataHandlerTests.cs | 22 +++++++++ .../OpenApiSerializerContractTests.cs | 3 +- ErsatzTV/wwwroot/openapi/v1.json | 16 ++++++- docs/api-conventions.md | 8 ++++ docs/domain-model.md | 2 +- web/src/api/generated/v1.d.ts | 2 + web/src/screens/ChannelsScreen.tsx | 2 +- web/src/screens/GuideScreen.tsx | 2 +- 13 files changed, 136 insertions(+), 7 deletions(-) diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index 57b94a558..52bb4311f 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -109,7 +109,8 @@ internal static class Mapper GetStreamingMode(channel), channel.IsEnabled, channel.ShowInEpg, - playoutCount); + playoutCount, + GetLogoUrl(channel)); internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) => new(resolution.Height, resolution.Width); @@ -123,6 +124,31 @@ internal static class Mapper channel.FFmpegProfile.VideoProfile, channel.FFmpegProfile.AudioFormat); + // Rooted, directly-usable channel-logo URL for the SPA's on browse surfaces (guide grid + + // channels list), following the #181 artwork convention (docs/api-conventions.md §4): the SPA does no + // client-side path building. External logo URLs pass through as-is; an uploaded logo ("iptv/logos/{file}") + // is rooted with a leading slash so it resolves against the site root regardless of the current SPA route. + // Returns null when the channel has no logo, so the SPA falls back to the generated initials "bug". +#nullable enable + internal static string? GetLogoUrl(Channel channel) + { + // Browse surfaces must not crash the whole list over a missing Artwork include; GetLogo assumes + // the caller included Channel.Artwork (GetAll + the guide query do), but stay defensive here. + if (channel.Artwork is null) + { + return null; + } + + ArtworkContentTypeModel logo = GetLogo(channel); + if (string.IsNullOrWhiteSpace(logo.Path)) + { + return null; + } + + return logo.IsExternalUrl || logo.Path.StartsWith('/') ? logo.Path : $"/{logo.Path}"; + } +#nullable restore + private static ArtworkContentTypeModel GetLogo(Channel channel) { Option maybeArtwork = channel.Artwork diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs index b4294b068..823a1c713 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs @@ -47,6 +47,7 @@ public class GetChannelGuideDataHandler( List channels = await dbContext.Channels .AsNoTracking() .Where(c => c.ShowInEpg) + .Include(c => c.Artwork) .Include(c => c.MirrorSourceChannel) .ToListAsync(cancellationToken); @@ -121,6 +122,7 @@ public class GetChannelGuideDataHandler( new ChannelGuideChannelResponseModel( channel.Number, channel.Name, + Mapper.GetLogoUrl(channel), programmes.OrderBy(p => p.Start).ToList())); } diff --git a/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs index cc968cff0..2690b32d0 100644 --- a/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs +++ b/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs @@ -16,6 +16,9 @@ public record ChannelGuideProgrammeResponseModel( public record ChannelGuideChannelResponseModel( string Number, string Name, + // Rooted, directly-usable logo URL for the SPA's ; null when the channel has no logo + // (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl. + string? Logo, List Programmes); /// The JSON channel-guide response: the resolved window plus per-channel programme arrays. diff --git a/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs index 55090149d..4eec0fefb 100644 --- a/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs +++ b/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs @@ -16,4 +16,7 @@ public record ChannelResponseModel( string StreamingMode, bool IsEnabled, bool ShowInEpg, - int PlayoutCount); + int PlayoutCount, + // Rooted, directly-usable logo URL for the SPA's ; null when the channel has no logo + // (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl. + string? Logo); diff --git a/ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerTests.cs index c43f41c2b..bc7de11de 100644 --- a/ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerTests.cs @@ -49,5 +49,53 @@ public class GetAllChannelsForApiHandlerTests channel.StreamingMode.ShouldBe("HLS Segmenter"); channel.IsEnabled.ShouldBeFalse(); channel.ShowInEpg.ShouldBeFalse(); + // No logo artwork -> null so the SPA renders its generated initials fallback. + channel.Logo.ShouldBeNull(); } + + [Test] + public async Task Should_Root_Uploaded_Logo_Url() + { + IChannelRepository repository = Substitute.For(); + repository.GetAll(Arg.Any()) + .Returns([NewChannel(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = "abc123.png" })]); + var handler = new GetAllChannelsForApiHandler(repository); + + List result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None); + + // Uploaded logos are addressed as "iptv/logos/{file}"; the browse DTO roots it (leading slash) so + // the SPA's resolves against the site root regardless of the current SPA route. + result.ShouldHaveSingleItem().Logo.ShouldBe("/iptv/logos/abc123.png"); + } + + [Test] + public async Task Should_Pass_Through_External_Logo_Url() + { + IChannelRepository repository = Substitute.For(); + repository.GetAll(Arg.Any()) + .Returns([NewChannel(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = "https://example.com/logo.png" })]); + var handler = new GetAllChannelsForApiHandler(repository); + + List result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None); + + // An absolute external URL is directly usable and must pass through unchanged (no leading slash added). + result.ShouldHaveSingleItem().Logo.ShouldBe("https://example.com/logo.png"); + } + + private static Channel NewChannel(params Artwork[] artwork) => + new(Guid.NewGuid()) + { + Id = 7, + Number = "7.1", + SortNumber = 7.1, + Name = "Retro Cartoons", + Group = "Kids", + Categories = "animation", + FFmpegProfile = new FFmpegProfile { Name = "HLS 720p" }, + PreferredAudioLanguageCode = "eng", + StreamingMode = StreamingMode.HttpLiveStreamingSegmenter, + IsEnabled = false, + ShowInEpg = false, + Artwork = [.. artwork] + }; } diff --git a/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs index 806c7a64d..f313a429b 100644 --- a/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs @@ -240,6 +240,28 @@ public class GetChannelGuideDataHandlerTests sourceItem.Finish.ShouldBe(BaseTime.AddHours(1)); } + [Test] + public async Task Handle_Should_Root_Uploaded_Logo_And_Null_When_Absent() + { + await using (TvContext context = _db.CreateContext()) + { + DomainChannel withLogo = NewChannel("2", "WithLogo", showInEpg: true); + withLogo.Artwork = [new Artwork { ArtworkKind = ArtworkKind.Logo, Path = "abc123.png" }]; + DomainChannel withoutLogo = NewChannel("3", "NoLogo", showInEpg: true); + context.Channels.AddRange(withLogo, withoutLogo); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + // Uploaded logo is rooted for the SPA's ; a channel with no logo returns null so the SPA + // renders its generated initials fallback. + result.Channels.Single(c => c.Number == "2").Logo.ShouldBe("/iptv/logos/abc123.png"); + result.Channels.Single(c => c.Number == "3").Logo.ShouldBeNull(); + } + [Test] public async Task Handle_Should_Return_Empty_Programmes_For_Channel_Without_Playout() { diff --git a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs index 12788ea0d..c37c624de 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs @@ -152,7 +152,8 @@ public class OpenApiSerializerContractTests "TransportStream", true, true, - 2); + 2, + "/iptv/logos/logo.png"); private static string FindOpenApiDocument() { diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 00df8e51a..8ad8375e3 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -23710,6 +23710,7 @@ "required": [ "number", "name", + "logo", "programmes" ], "type": "object", @@ -23720,6 +23721,12 @@ "name": { "type": "string" }, + "logo": { + "type": [ + "null", + "string" + ] + }, "programmes": { "type": "array", "items": { @@ -23880,7 +23887,8 @@ "streamingMode", "isEnabled", "showInEpg", - "playoutCount" + "playoutCount", + "logo" ], "type": "object", "properties": { @@ -23922,6 +23930,12 @@ "playoutCount": { "type": "integer", "format": "int32" + }, + "logo": { + "type": [ + "null", + "string" + ] } } }, diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 0bc0bd96f..be1f57efe 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -275,6 +275,14 @@ Do **not** reuse the Application-layer Mappers used by Blazor (e.g. `MediaCards` mappers) for new API DTOs — those still return the old Blazor-convention relative paths. Map from the domain/VM directly and root the path yourself, following the PR #181 pattern. +Channel **logos** live under a different route than posters/thumbnails: an uploaded logo roots to +`/iptv/logos/{file}` (served by `IptvController`), and an external logo is an absolute URL passed +through unchanged. Browse-surface DTOs (`ChannelResponseModel` list, `ChannelGuideChannelResponseModel` +guide) get this rooted `Logo` URL from the single `Channels.Mapper.GetLogoUrl` helper (#464), which +returns `null` when the channel has no logo so the SPA falls back to its generated initials icon. The +raw un-rooted `{path, contentType}` form is still used only by the channel **editor** DTO +(`ChannelDetailResponseModel.Logo`), which round-trips it back on save. + ### 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/domain-model.md b/docs/domain-model.md index 01ee195a6..3e406ee89 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -81,7 +81,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe | **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` | | **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384; per-channel `templateId`/`advanced`/`logo` overrides accepted by `POST /api/v1/channels/auto-tune`, #385; per-source rotation weights + query corrections via an optional `sources: [{sourceId, weight, excluded}]` on that same request, #425 — a customized channel is backed by a system-owned `MultiCollection` of per-source `SmartCollection`s with `PlaybackOrder.WeightedShuffle`, `OwnedByChannelId`-tagged so it's hidden from collection lists and cleaned up on channel delete) | | **Channel health / `PlayoutCount`** (#72) | Whether a channel can play at all. `PlayoutCount` (channel's own playouts, **plus the mirror source's** when `PlayoutSource is Mirror` — computed by `Mapper.GetPlayoutsCount`) rides on both `ChannelResponseModel` (list) and `ChannelDetailResponseModel`; `0` ⇒ the channel can never play, rendered as a "No playout" badge + a matching **No playout** filter on the channels list (both name only the one fault the API can prove — a broader "Problems" label would read as a false all-clear to a user whose *other* fault classes below are uncomputed). It is a **raw fact, not a status enum** — see `decisions.md` 2026-07-17. Distinct from `/api/v1/channels/state`'s `OnAir`, which is runtime liveness ("someone is streaming right now"), not "would play if tuned". Empty-schedule, broken-source and auto-tuned-vs-user origin are deliberately **not** computed (see that decision entry for why each is unsafe today). | `Channel.Playouts` | `/app/channels` (read-only signal) | -| **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. | `GetChannelGuideHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` | +| **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. The SPA's JSON guide grid is built by the sibling `GetChannelGuideDataHandler`. Both the JSON guide (`ChannelGuideChannelResponseModel`) and the channels-list DTO (`ChannelResponseModel`) expose a rooted, directly-usable `Logo` URL (#464) via `Mapper.GetLogoUrl` — `/iptv/logos/{file}` for an uploaded logo, the absolute URL passed through for an external one, `null` when unset (SPA then draws the generated initials "bug"). | `GetChannelGuideHandler`, `GetChannelGuideDataHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` | | **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — | | **IPTV base URL** | Optional advertised base URL for the IPTV surface (#340). Stored as a single `ConfigElement` (`ConfigElementKey.IptvBaseUrl`, key `iptv.base_url`, no EF migration); when set, `GetChannelPlaylistHandler` (M3U) and `GetChannelGuideHandler` (XMLTV) pin their absolute URLs to its scheme/host/base instead of the request `Host` (blank/invalid → request-derived). Not applied to HDHomeRun; distinct from `ETV_BASE_URL`. Parsed by `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`. | `ConfigElementKey.IptvBaseUrl` | `/app/settings` → IPTV (`GET`/`PUT /api/v1/settings/iptv`) | | **Aggregate `Version`** | Optimistic-concurrency token (issue #253): a plain `int Version` on the 9 replace-all roots — `ProgramSchedule`, `Block`, `Template`, `DecoTemplate`, `Playlist`, `Collection`, `Playout`, `MultiCollection`, `RerunCollection` — implementing `IVersionedAggregate`, EF-mapped `.IsConcurrencyToken()`. Surfaced as a strong ETag on the aggregate's GET and checked against `If-Match` on the PUT (mismatch → 412). See `api-conventions.md` §7a. | `IVersionedAggregate` | (not user-edited) | diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index eafeced82..5d5b8511e 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -205,6 +205,7 @@ export interface components { "ChannelGuideChannelResponseModel": { "number": string; "name": string; + "logo": null | string; "programmes": Array; }; "ChannelGuideProgrammeResponseModel": { @@ -249,6 +250,7 @@ export interface components { "isEnabled": boolean; "showInEpg": boolean; "playoutCount": number; + "logo": null | string; }; "ChannelSongVideoMode": "Default" | "WithProgress"; "ChannelStateResponseModel": { diff --git a/web/src/screens/ChannelsScreen.tsx b/web/src/screens/ChannelsScreen.tsx index 817d07b87..d13bc84dd 100644 --- a/web/src/screens/ChannelsScreen.tsx +++ b/web/src/screens/ChannelsScreen.tsx @@ -553,7 +553,7 @@ function ChannelTableRow({
- +
{channel.name} diff --git a/web/src/screens/GuideScreen.tsx b/web/src/screens/GuideScreen.tsx index 5abbef7d8..8b3961be7 100644 --- a/web/src/screens/GuideScreen.tsx +++ b/web/src/screens/GuideScreen.tsx @@ -182,7 +182,7 @@ function GuideChannelRow({
{channel.number} - + {channel.name} {channelState?.onAir && }
-- 2.47.3