Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 22s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m2s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 6m30s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fixes #99 Co-Authored-By: OpenAI Codex <codex@openai.com>
155 lines
7.5 KiB
C#
155 lines
7.5 KiB
C#
using ErsatzTV.Core.Api.Channels;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Domain.Filler;
|
|
using ErsatzTV.Core.Interfaces.FFmpeg;
|
|
using ErsatzTV.Core.Interfaces.Streaming;
|
|
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,
|
|
IDirectStreamSessionTracker directStreamSessionTracker)
|
|
: 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) ||
|
|
directStreamSessionTracker.IsActive(channel.Number),
|
|
nowPlaying);
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private sealed record CoveringItem(int Id, int PlayoutId, int ChannelId, int GuideGroup, DateTime Start);
|
|
}
|