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 }; }