Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m33s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m11s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 36m5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
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 <ChannelLogo> 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) <noreply@anthropic.com>
148 lines
6.6 KiB
C#
148 lines
6.6 KiB
C#
using System.Globalization;
|
|
using ErsatzTV.Application.Configuration;
|
|
using ErsatzTV.Core.Api.Channels;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Repositories;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.Extensions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ErsatzTV.Application.Channels;
|
|
|
|
/// <summary>
|
|
/// Builds the JSON channel guide directly from <see cref="Playout" /> items, using the shared
|
|
/// <see cref="ChannelGuideProjector" /> guide-group/filler-merge logic (the same logic the XMLTV
|
|
/// cache builder uses) so the two representations cannot drift. Only channels with
|
|
/// <see cref="Channel.ShowInEpg" /> are included, mirroring <c>GetChannelGuideHandler</c>.
|
|
/// Unlike XMLTV, filler programmes are returned (with their <see cref="Core.Domain.Filler.FillerKind" />)
|
|
/// so the UI can decide how to render them.
|
|
/// </summary>
|
|
public class GetChannelGuideDataHandler(
|
|
IDbContextFactory<TvContext> dbContextFactory,
|
|
IConfigElementRepository configElementRepository)
|
|
: IRequestHandler<GetChannelGuideData, ChannelGuideResponseModel>
|
|
{
|
|
public async Task<ChannelGuideResponseModel> Handle(
|
|
GetChannelGuideData request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
int daysToBuild = await configElementRepository
|
|
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
|
|
.IfNoneAsync(2);
|
|
|
|
XmltvTimeZone xmltvTimeZone = await configElementRepository
|
|
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
|
|
.IfNoneAsync(XmltvTimeZone.Local);
|
|
|
|
XmltvBlockBehavior xmltvBlockBehavior = await configElementRepository
|
|
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
|
|
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
|
|
|
|
DateTimeOffset start = request.Start ?? DateTimeOffset.UtcNow;
|
|
DateTimeOffset end = request.End ?? start.AddDays(daysToBuild);
|
|
|
|
// Visible channels only (mirror GetChannelGuideHandler's ShowInEpg == false skip).
|
|
List<Channel> channels = await dbContext.Channels
|
|
.AsNoTracking()
|
|
.Where(c => c.ShowInEpg)
|
|
.Include(c => c.Artwork)
|
|
.Include(c => c.MirrorSourceChannel)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
// Order channels by their decimal channel number so "2" precedes "10", matching
|
|
// ChannelGuide.ToXml (which orders XMLTV channels by decimal.Parse of the number).
|
|
channels = channels
|
|
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture))
|
|
.ToList();
|
|
|
|
var responseChannels = new List<ChannelGuideChannelResponseModel>();
|
|
|
|
foreach (Channel channel in channels)
|
|
{
|
|
bool isMirror = channel.PlayoutSource == ChannelPlayoutSource.Mirror
|
|
&& channel.MirrorSourceChannel is not null;
|
|
|
|
string sourceChannelNumber = isMirror ? channel.MirrorSourceChannel.Number : channel.Number;
|
|
TimeSpan playoutOffset = isMirror ? channel.PlayoutOffset ?? TimeSpan.Zero : TimeSpan.Zero;
|
|
|
|
List<Playout> playouts = await dbContext.Playouts
|
|
.AsNoTracking()
|
|
.Filter(p => p.Channel.Number == sourceChannelNumber)
|
|
.IncludeGuideMetadata()
|
|
.AsSplitQuery()
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var programmes = new List<ChannelGuideProgrammeResponseModel>();
|
|
|
|
foreach (Playout playout in playouts)
|
|
{
|
|
// ExternalJson playouts materialize items from a file rather than Playout.Items; they are
|
|
// out of scope for the JSON guide (see issue #102 notes).
|
|
if (playout.ScheduleKind is PlayoutScheduleKind.ExternalJson)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Filter to the window (on the pre-offset time, mirroring the XMLTV builder) then apply the
|
|
// mirror playout offset without mutating the loaded (shared, AsNoTracking) entities.
|
|
List<PlayoutItem> sorted = playout.Items
|
|
.OrderBy(pi => pi.Start)
|
|
.Filter(pi => pi.StartOffset <= end)
|
|
.Select(pi => playoutOffset == TimeSpan.Zero ? pi : WithPlayoutOffset(pi, playoutOffset))
|
|
.ToList();
|
|
|
|
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
|
|
playout.ScheduleKind,
|
|
sorted,
|
|
xmltvTimeZone,
|
|
xmltvBlockBehavior))
|
|
{
|
|
// drop programmes that finish before the requested window starts
|
|
if (entry.Stop <= start)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string subtitle = ChannelGuideMetadata.GetSubtitle(entry.DisplayItem);
|
|
|
|
programmes.Add(
|
|
new ChannelGuideProgrammeResponseModel(
|
|
entry.Start,
|
|
entry.Stop,
|
|
ChannelGuideMetadata.GetTitle(entry.DisplayItem),
|
|
string.IsNullOrWhiteSpace(subtitle) ? null : subtitle,
|
|
ChannelGuideMetadata.GetCategory(entry.DisplayItem),
|
|
entry.DisplayItem.FillerKind));
|
|
}
|
|
}
|
|
|
|
responseChannels.Add(
|
|
new ChannelGuideChannelResponseModel(
|
|
channel.Number,
|
|
channel.Name,
|
|
Mapper.GetLogoUrl(channel),
|
|
programmes.OrderBy(p => p.Start).ToList()));
|
|
}
|
|
|
|
return new ChannelGuideResponseModel(start, end, responseChannels);
|
|
}
|
|
|
|
// Copy (don't mutate) the loaded PlayoutItem when shifting by the mirror playout offset. The loaded
|
|
// entities are AsNoTracking and shared; mutating them in place would corrupt the guide projection.
|
|
// Mirrors the XMLTV builder, which shifts only Start/Finish (not the Guide* window).
|
|
private static PlayoutItem WithPlayoutOffset(PlayoutItem item, TimeSpan offset) =>
|
|
new()
|
|
{
|
|
MediaItem = item.MediaItem,
|
|
Start = item.Start + offset,
|
|
Finish = item.Finish + offset,
|
|
GuideStart = item.GuideStart,
|
|
GuideFinish = item.GuideFinish,
|
|
GuideGroup = item.GuideGroup,
|
|
FillerKind = item.FillerKind,
|
|
CustomTitle = item.CustomTitle
|
|
};
|
|
}
|