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;
///
/// Builds the JSON channel guide directly from items, using the shared
/// guide-group/filler-merge logic (the same logic the XMLTV
/// cache builder uses) so the two representations cannot drift. Only channels with
/// are included, mirroring GetChannelGuideHandler.
/// Unlike XMLTV, filler programmes are returned (with their )
/// so the UI can decide how to render them.
///
public class GetChannelGuideDataHandler(
IDbContextFactory dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler
{
public async Task Handle(
GetChannelGuideData request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int daysToBuild = await configElementRepository
.GetValue(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
.IfNoneAsync(2);
XmltvTimeZone xmltvTimeZone = await configElementRepository
.GetValue(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
XmltvBlockBehavior xmltvBlockBehavior = await configElementRepository
.GetValue(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 channels = await dbContext.Channels
.AsNoTracking()
.Where(c => c.ShowInEpg)
.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();
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 playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(p => p.Channel.Number == sourceChannelNumber)
.IncludeGuideMetadata()
.AsSplitQuery()
.ToListAsync(cancellationToken);
var programmes = new List();
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 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,
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
};
}