Adversarial review fixes for the #97 endpoint: - nowPlaying now resolves like the XMLTV guide: when the wall-clock item is pre/mid-roll filler, surface the guide group's program item (title + full program bounds) instead of the filler; a guide entry with no program item (e.g. fallback loop) reports null. Previously an ad break would retitle the channel and reset progress every 30 seconds. - covering-item lookup now runs one cheap projected query per distinct playout offset instead of hydrating the full metadata include tree for a window widened by the largest offset across ALL channels (up to 24h); metadata is fetched in a second query scoped to the covering guide groups, time-bounded to stay safe against GuideGroup recycling (mod 10000). Regression caught while testing: covering items are keyed by (source channel, lookup time) so a source channel no longer inherits its offset mirror's lookup result. - GetChannelStatesForApi now carries Now explicitly (controller passes DateTime.UtcNow), making boundary semantics testable: new tests pin now == Start (playing) and now == Finish (not playing), plus mid-roll, filler-only, and mirror-source-row cases. - ChannelNowPlayingResponseModel title is non-nullable in the schema (#nullable enable) — GetDisplayTitle always returns a string. - GetState uses the typed-return controller convention; Playouts.Mapper reverted to internal (handler lives in the same assembly). - Spec doc notes the OnDemand drift caveat and filler semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
152 lines
7.4 KiB
C#
152 lines
7.4 KiB
C#
using ErsatzTV.Core.Api.Channels;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Domain.Filler;
|
|
using ErsatzTV.Core.Interfaces.FFmpeg;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
|
|
|
|
namespace ErsatzTV.Application.Channels;
|
|
|
|
public class GetChannelStatesForApiHandler(
|
|
IDbContextFactory<TvContext> dbContextFactory,
|
|
IFFmpegSegmenterService ffmpegSegmenterService)
|
|
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
|
|
{
|
|
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
|
|
// bound also protects against GuideGroup values recycling (mod 10000) elsewhere in a playout
|
|
private static readonly TimeSpan GuideEntryBound = TimeSpan.FromDays(1);
|
|
|
|
public async Task<List<ChannelStateResponseModel>> Handle(
|
|
GetChannelStatesForApi request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
List<Channel> channels = await dbContext.Channels
|
|
.AsNoTracking()
|
|
.OrderBy(c => c.SortNumber)
|
|
.ThenBy(c => c.Number)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
if (channels.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
Dictionary<int, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> channelLookup = channels
|
|
.ToDictionary(
|
|
c => c.Id,
|
|
c =>
|
|
{
|
|
TimeSpan offset = c.PlayoutOffset ?? TimeSpan.Zero;
|
|
return (c.MirrorSourceChannelId ?? c.Id, offset, request.Now - offset);
|
|
});
|
|
|
|
// one covering-item query per distinct lookup time (i.e. per distinct playout offset,
|
|
// typically just one) with a cheap projection; metadata is hydrated in a second query
|
|
// for only the covering items' guide groups
|
|
var coveringBySourceAndTime = new Dictionary<(int SourceChannelId, DateTime LookupTime), CoveringItem>();
|
|
foreach (IGrouping<DateTime, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> timeGroup in
|
|
channelLookup.Values.GroupBy(v => v.LookupTime))
|
|
{
|
|
DateTime lookupTime = timeGroup.Key;
|
|
int[] sourceChannelIds = timeGroup.Map(v => v.SourceChannelId).Distinct().ToArray();
|
|
|
|
List<CoveringItem> covering = await dbContext.PlayoutItems
|
|
.AsNoTracking()
|
|
.Where(pi => sourceChannelIds.Contains(pi.Playout.ChannelId))
|
|
.Where(pi => pi.Start <= lookupTime && pi.Finish > lookupTime)
|
|
.Select(pi => new CoveringItem(pi.Id, pi.PlayoutId, pi.Playout.ChannelId, pi.GuideGroup, pi.Start))
|
|
.ToListAsync(cancellationToken);
|
|
|
|
foreach (CoveringItem item in covering.OrderBy(ci => ci.Start))
|
|
{
|
|
coveringBySourceAndTime.TryAdd((item.ChannelId, lookupTime), item);
|
|
}
|
|
}
|
|
|
|
Dictionary<(int PlayoutId, int GuideGroup), List<PlayoutItem>> itemsByGuideEntry = [];
|
|
if (coveringBySourceAndTime.Count > 0)
|
|
{
|
|
int[] playoutIds = coveringBySourceAndTime.Values.Map(ci => ci.PlayoutId).Distinct().ToArray();
|
|
int[] guideGroups = coveringBySourceAndTime.Values.Map(ci => ci.GuideGroup).Distinct().ToArray();
|
|
DateTime windowStart = channelLookup.Values.Min(v => v.LookupTime) - GuideEntryBound;
|
|
DateTime windowFinish = channelLookup.Values.Max(v => v.LookupTime) + GuideEntryBound;
|
|
|
|
List<PlayoutItem> guideEntryItems = await dbContext.PlayoutItems
|
|
.AsNoTracking()
|
|
.Include(pi => pi.MediaItem)
|
|
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
|
|
.Include(pi => pi.MediaItem)
|
|
.ThenInclude(mi => (mi as Episode).Season)
|
|
.ThenInclude(s => s.Show)
|
|
.ThenInclude(s => s.ShowMetadata)
|
|
.Include(pi => pi.MediaItem)
|
|
.ThenInclude(mi => (mi as Movie).MovieMetadata)
|
|
.Include(pi => pi.MediaItem)
|
|
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
|
.Include(pi => pi.MediaItem)
|
|
.ThenInclude(mi => (mi as MusicVideo).Artist)
|
|
.ThenInclude(a => a.ArtistMetadata)
|
|
.Include(pi => pi.MediaItem)
|
|
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
|
|
.Include(pi => pi.MediaItem)
|
|
.ThenInclude(mi => (mi as Song).SongMetadata)
|
|
.Include(pi => pi.MediaItem)
|
|
.ThenInclude(mi => (mi as Image).ImageMetadata)
|
|
.Include(pi => pi.MediaItem)
|
|
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
|
|
.Where(pi => playoutIds.Contains(pi.PlayoutId) && guideGroups.Contains(pi.GuideGroup))
|
|
.Where(pi => pi.Start <= windowFinish && pi.Finish > windowStart)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
itemsByGuideEntry = guideEntryItems
|
|
.GroupBy(pi => (pi.PlayoutId, pi.GuideGroup))
|
|
.ToDictionary(g => g.Key, g => g.OrderBy(pi => pi.Start).ToList());
|
|
}
|
|
|
|
return channels
|
|
.Map(channel =>
|
|
{
|
|
(int sourceChannelId, TimeSpan offset, DateTime lookupTime) = channelLookup[channel.Id];
|
|
ChannelNowPlayingResponseModel nowPlaying = null;
|
|
|
|
if (coveringBySourceAndTime.TryGetValue((sourceChannelId, lookupTime), out CoveringItem covering) &&
|
|
itemsByGuideEntry.TryGetValue(
|
|
(covering.PlayoutId, covering.GuideGroup),
|
|
out List<PlayoutItem> guideEntry))
|
|
{
|
|
// like the XMLTV guide, surface the program rather than its filler: use the
|
|
// covering item itself when it is a program part, otherwise the guide entry's
|
|
// first program item; an entry with no program item (e.g. offline fallback
|
|
// filler) stays null
|
|
PlayoutItem coveringItem = guideEntry.Find(pi => pi.Id == covering.Id);
|
|
PlayoutItem displayItem = coveringItem?.FillerKind == FillerKind.None
|
|
? coveringItem
|
|
: guideEntry.Find(pi => pi.FillerKind == FillerKind.None);
|
|
|
|
if (displayItem is not null)
|
|
{
|
|
DateTime start = guideEntry[0].Start;
|
|
DateTime finish = displayItem.GuideFinish ?? guideEntry.Max(pi => pi.Finish);
|
|
|
|
nowPlaying = new ChannelNowPlayingResponseModel(
|
|
PlayoutMapper.GetDisplayTitle(displayItem.MediaItem, Optional(displayItem.ChapterTitle)),
|
|
new DateTimeOffset(start + offset, TimeSpan.Zero),
|
|
new DateTimeOffset(finish + offset, TimeSpan.Zero));
|
|
}
|
|
}
|
|
|
|
return new ChannelStateResponseModel(
|
|
channel.Id,
|
|
channel.Number,
|
|
ffmpegSegmenterService.IsActive(channel.Number),
|
|
nowPlaying);
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private sealed record CoveringItem(int Id, int PlayoutId, int ChannelId, int GuideGroup, DateTime Start);
|
|
}
|