Add an authenticated playback-source endpoint for player-owned playback and document the Jellyfin Live TV proof-of-concept boundary. Cache guide snapshots for the probe plugin while leaving native Jellyfin source handling in control. Refs #357 Co-Authored-By: OpenAI Codex <codex@openai.com>
147 lines
6.6 KiB
C#
147 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.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.Id,
|
|
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
|
|
};
|
|
}
|