fix(464): render real channel logos in guide grid + channels list #467

Merged
timothy merged 1 commits from fix/464-guide-logos into main 2026-07-19 14:10:29 +02:00
13 changed files with 136 additions and 7 deletions
+27 -1
View File
@@ -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 <img src> 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<Artwork> maybeArtwork = channel.Artwork
@@ -47,6 +47,7 @@ public class GetChannelGuideDataHandler(
List<Channel> 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()));
}
@@ -16,6 +16,9 @@ public record ChannelGuideProgrammeResponseModel(
public record ChannelGuideChannelResponseModel(
string Number,
string Name,
// Rooted, directly-usable logo URL for the SPA's <img src>; null when the channel has no logo
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
string? Logo,
List<ChannelGuideProgrammeResponseModel> Programmes);
/// <summary>The JSON channel-guide response: the resolved window plus per-channel programme arrays.</summary>
@@ -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 <img src>; null when the channel has no logo
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
string? Logo);
@@ -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<IChannelRepository>();
repository.GetAll(Arg.Any<CancellationToken>())
.Returns([NewChannel(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = "abc123.png" })]);
var handler = new GetAllChannelsForApiHandler(repository);
List<ChannelResponseModel> 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 <img src> 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<IChannelRepository>();
repository.GetAll(Arg.Any<CancellationToken>())
.Returns([NewChannel(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = "https://example.com/logo.png" })]);
var handler = new GetAllChannelsForApiHandler(repository);
List<ChannelResponseModel> 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]
};
}
@@ -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 <img src>; 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()
{
@@ -152,7 +152,8 @@ public class OpenApiSerializerContractTests
"TransportStream",
true,
true,
2);
2,
"/iptv/logos/logo.png");
private static string FindOpenApiDocument()
{
+15 -1
View File
@@ -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"
]
}
}
},
+8
View File
@@ -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
+1 -1
View File
@@ -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) |
+2
View File
@@ -205,6 +205,7 @@ export interface components {
"ChannelGuideChannelResponseModel": {
"number": string;
"name": string;
"logo": null | string;
"programmes": Array<components["schemas"]["ChannelGuideProgrammeResponseModel"]>;
};
"ChannelGuideProgrammeResponseModel": {
@@ -249,6 +250,7 @@ export interface components {
"isEnabled": boolean;
"showInEpg": boolean;
"playoutCount": number;
"logo": null | string;
};
"ChannelSongVideoMode": "Default" | "WithProgress";
"ChannelStateResponseModel": {
+1 -1
View File
@@ -553,7 +553,7 @@ function ChannelTableRow({
</td>
<td>
<div className="ctv-channel-identity">
<ChannelLogo name={channel.name} size={34} />
<ChannelLogo name={channel.name} size={34} src={channel.logo} />
<div>
<strong>{channel.name}</strong>
<span className="ctv-channel-markers">
+1 -1
View File
@@ -182,7 +182,7 @@ function GuideChannelRow({
<div className="ctv-epg-row" role="row" style={{ height: EPG_ROW_HEIGHT }}>
<div className="ctv-epg-channel-rail" role="rowheader" style={{ width: EPG_RAIL_WIDTH }}>
<code>{channel.number}</code>
<ChannelLogo name={channel.name} size={30} />
<ChannelLogo name={channel.name} size={30} src={channel.logo} />
<span>{channel.name}</span>
{channelState?.onAir && <StatusDot status="live" size={7} />}
</div>