Compare commits

..
Author SHA1 Message Date
timothy 0585f4a7f8 test(api): pin mirror-offset guide behavior; clarify guide end-default wording (#102)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Add GetChannelGuideDataHandlerTests coverage for the mirror-channel path:
  asserts programme Start/Stop shift by PlayoutOffset and that the source
  channel's seeded PlayoutItem entities are not mutated, pinning the
  WithPlayoutOffset copy fix.
- ChannelController.GetGuide Swagger description said end defaults to "now
  plus XmltvDaysToBuild"; it actually defaults to start plus that window
  (matters when start is supplied). Reworded and regenerated v1.json.
2026-07-04 01:19:58 +02:00
timothyandClaude Fable 5 ef024c0a05 chore(openapi): regenerate v1 for JSON channel guide endpoint (#102)
refs #102

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:55:07 +02:00
timothyandClaude Fable 5 12aa57ccc9 test(api): cover JSON channel guide handler and endpoint (#102)
Handler tests (in-memory SQLite harness): ShowInEpg filtering, decimal channel
ordering, time-window filtering, custom-title passthrough, leading pre-roll filler
merge, block split-time-evenly, default start/end from config, empty guide.
Controller tests: query mapping and null-bounds passthrough for GetGuide.

refs #102

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:55:07 +02:00
timothyandClaude Fable 5 9ecefd75f3 feat(api): add JSON channel guide endpoint (#102)
Add GET /api/guide returning a per-channel EPG grid (ChannelGuideResponseModel).
start defaults to now, end defaults to now + XmltvDaysToBuild.

Extract the guide-group/filler-merge projection (ChannelGuideProjector) and
programme-metadata resolution (ChannelGuideMetadata) plus the metadata eager-load
(IncludeGuideMetadata) out of RefreshChannelDataHandler so the XMLTV cache builder
and the JSON guide share one source of truth and cannot drift. XMLTV output is
unchanged (ChannelGuideGoldenTests pass without regeneration).

The JSON handler mirrors XMLTV semantics: ShowInEpg-only channels, decimal channel
ordering, ExternalJson playouts skipped, mirror playout offset applied to copies
(never mutating the loaded entities). SubTitle/Category are nullable.

refs #102

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:54:58 +02:00
timothy 43f81d3a49 Merge pull request 'feat(api): schedule item duration estimates (#111)' (#118) from feat/111-schedule-durations into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m14s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m36s
2026-07-03 22:00:32 +00:00
11 changed files with 1045 additions and 315 deletions
@@ -0,0 +1,71 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Shared programme-metadata projection for guide output. Both the XMLTV cache builder
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
/// (<see cref="GetChannelGuideDataHandler" />) resolve the display title/subtitle/category from a
/// <see cref="PlayoutItem" /> here so the two representations stay consistent.
/// </summary>
public static class ChannelGuideMetadata
{
public static string GetTitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return playoutItem.CustomTitle;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]"
};
}
public static string GetSubtitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
Song s => s.SongMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
/// <summary>
/// The primary guide category, mirroring the fixed <c>&lt;category&gt;</c> the XMLTV templates
/// emit per media kind (Movie / Series / Music). Media kinds without a fixed category return null.
/// </summary>
public static string GetCategory(PlayoutItem playoutItem) =>
playoutItem.MediaItem switch
{
Movie => "Movie",
Episode => "Series",
MusicVideo => "Music",
Song => "Music",
_ => null
};
}
@@ -0,0 +1,164 @@
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// A single guide programme resolved from one or more <see cref="PlayoutItem" />s: the
/// <see cref="DisplayItem" /> whose metadata is shown, plus the coalesced <see cref="Start" />/
/// <see cref="Stop" /> window and whether the originating item carried a custom title.
/// </summary>
public readonly record struct ChannelGuideEntry(
PlayoutItem DisplayItem,
DateTimeOffset Start,
DateTimeOffset Stop,
bool HasCustomTitle);
/// <summary>
/// Shared guide-group / filler-merge projection. This is the single source of truth for turning a
/// channel's sorted <see cref="PlayoutItem" />s into guide programmes; both the XMLTV cache builder
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
/// (<see cref="GetChannelGuideDataHandler" />) consume it so the two representations cannot drift.
/// The XMLTV path formats <see cref="ChannelGuideEntry.Start" />/<see cref="ChannelGuideEntry.Stop" />
/// into the XMLTV timestamp strings; the JSON path returns them (and the display item's
/// <see cref="FillerKind" />) directly and lets the UI decide how to render filler.
/// </summary>
public static class ChannelGuideProjector
{
public static IEnumerable<ChannelGuideEntry> Project(
PlayoutScheduleKind scheduleKind,
IReadOnlyList<PlayoutItem> sorted,
XmltvTimeZone timeZone,
XmltvBlockBehavior blockBehavior) =>
scheduleKind switch
{
PlayoutScheduleKind.Block => ProjectBlock(sorted, timeZone, blockBehavior),
_ => ProjectFlood(sorted, timeZone)
};
// Classic / Sequential / Scripted / ExternalJson: skip leading non-preroll filler, then coalesce
// each guide group (following filler) into a single programme using the display item's GuideFinish
// override when present.
private static IEnumerable<ChannelGuideEntry> ProjectFlood(
IReadOnlyList<PlayoutItem> sorted,
XmltvTimeZone timeZone)
{
// skip all filler that isn't pre-roll
var i = 0;
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
sorted[i].FillerKind != FillerKind.PreRoll)
{
i++;
}
while (i < sorted.Count)
{
PlayoutItem startItem = sorted[i];
int j = i;
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
{
j++;
}
PlayoutItem displayItem = sorted[j];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
or FillerKind.PostRoll or FillerKind.Tail
or FillerKind.Fallback or FillerKind.DecoDefault))
{
finishIndex++;
}
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
DateTimeOffset startTime = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
_ => startItem.StartOffset
};
DateTimeOffset stopTime = (timeZone, displayItem.GuideFinishOffset.HasValue) switch
{
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
(_, true) => displayItem.GuideFinishOffset!.Value,
(_, false) => finishItem.FinishOffset
};
yield return new ChannelGuideEntry(displayItem, startTime, stopTime, hasCustomTitle);
i++;
}
}
// Block: group by guide window, drop filler entirely, then either use the items' actual times or
// split the group window evenly across the non-filler items.
private static IEnumerable<ChannelGuideEntry> ProjectBlock(
IReadOnlyList<PlayoutItem> sorted,
XmltvTimeZone timeZone,
XmltvBlockBehavior blockBehavior)
{
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
{
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
if (itemsToInclude.Count == 0)
{
continue;
}
switch (blockBehavior)
{
case XmltvBlockBehavior.UseActualTimes:
foreach (PlayoutItem item in itemsToInclude)
{
DateTimeOffset actualStart = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset actualFinish = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
};
yield return new ChannelGuideEntry(item, actualStart, actualFinish, false);
}
break;
case XmltvBlockBehavior.SplitTimeEvenly:
default:
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset currentFinish = currentStart + perItem;
foreach (PlayoutItem item in itemsToInclude)
{
yield return new ChannelGuideEntry(item, currentStart, currentFinish, false);
currentStart = currentFinish;
currentFinish += perItem;
}
break;
}
}
}
}
@@ -129,89 +129,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ThenInclude(am => am.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.IncludeGuideMetadata()
.AsSplitQuery()
.ToListAsync(cancellationToken);
@@ -244,8 +162,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
item.Finish += playoutOffset;
}
await WritePlayoutXml(
await WriteScheduleXml(
request,
playout.ScheduleKind,
floodSorted,
templateContext,
movieTemplate,
@@ -270,8 +189,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
item.Finish += playoutOffset;
}
await WriteBlockPlayoutXml(
await WriteScheduleXml(
request,
playout.ScheduleKind,
blockSorted,
templateContext,
movieTemplate,
@@ -294,8 +214,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
item.Finish += playoutOffset;
}
await WritePlayoutXml(
await WriteScheduleXml(
request,
playout.ScheduleKind,
externalJsonSorted,
templateContext,
movieTemplate,
@@ -324,100 +245,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
}
}
private async Task WritePlayoutXml(
RefreshChannelData request,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
Template episodeTemplate,
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml,
CancellationToken cancellationToken)
{
XmltvTimeZone xmltvTimeZone = await _configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
// skip all filler that isn't pre-roll
var i = 0;
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
sorted[i].FillerKind != FillerKind.PreRoll)
{
i++;
}
while (i < sorted.Count)
{
PlayoutItem startItem = sorted[i];
int j = i;
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
{
j++;
}
PlayoutItem displayItem = sorted[j];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
or FillerKind.PostRoll or FillerKind.Tail
or FillerKind.Fallback or FillerKind.DecoDefault))
{
finishIndex++;
}
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
DateTimeOffset startTime = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
_ => startItem.StartOffset
};
DateTimeOffset stopTime = (xmltvTimeZone, displayItem.GuideFinishOffset.HasValue) switch
{
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
(_, true) => displayItem.GuideFinishOffset!.Value,
(_, false) => finishItem.FinishOffset
};
string start = startTime
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = stopTime
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
displayItem,
start,
stop,
hasCustomTitle,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
i++;
}
}
private async Task WriteBlockPlayoutXml(
private async Task WriteScheduleXml(
RefreshChannelData request,
PlayoutScheduleKind scheduleKind,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
@@ -438,98 +268,36 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
// guide-group / filler-merge logic is shared with the JSON guide query so the two cannot drift
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
scheduleKind,
sorted,
xmltvTimeZone,
xmltvBlockBehavior))
{
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
if (itemsToInclude.Count == 0)
{
continue;
}
string start = entry.Start
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
switch (xmltvBlockBehavior)
{
case XmltvBlockBehavior.UseActualTimes:
foreach (PlayoutItem item in itemsToInclude)
{
DateTimeOffset actualStart = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
};
string stop = entry.Stop
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
DateTimeOffset actualFinish = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
};
string start = actualStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = actualFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
}
break;
case XmltvBlockBehavior.SplitTimeEvenly:
default:
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset currentFinish = currentStart + perItem;
foreach (PlayoutItem item in itemsToInclude)
{
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
currentStart = currentFinish;
currentFinish += perItem;
}
break;
}
await WriteItemToXml(
request,
entry.DisplayItem,
start,
stop,
entry.HasCustomTitle,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
}
}
@@ -549,8 +317,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
XmlMinifier minifier,
XmlWriter xml)
{
string title = GetTitle(displayItem);
string subtitle = GetSubtitle(displayItem);
string title = ChannelGuideMetadata.GetTitle(displayItem);
string subtitle = ChannelGuideMetadata.GetSubtitle(displayItem);
Option<string> maybeTemplateOutput = displayItem.MediaItem switch
{
@@ -1117,51 +885,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return artworkPath;
}
private static string GetTitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return playoutItem.CustomTitle;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]"
};
}
private static string GetSubtitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
Song s => s.SongMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
private static string GetPrioritizedArtworkPath(Metadata metadata)
{
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// JSON channel-guide query for the EPG grid. <paramref name="Start" /> defaults to now and
/// <paramref name="End" /> defaults to now + the configured XmltvDaysToBuild window.
/// </summary>
public record GetChannelGuideData(DateTimeOffset? Start, DateTimeOffset? End)
: IRequest<ChannelGuideResponseModel>;
@@ -0,0 +1,145 @@
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.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
};
}
@@ -0,0 +1,25 @@
#nullable enable
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Channels;
/// <summary>A single guide programme for the JSON EPG grid.</summary>
public record ChannelGuideProgrammeResponseModel(
DateTimeOffset Start,
DateTimeOffset Stop,
string Title,
string? SubTitle,
string? Category,
FillerKind FillerKind);
/// <summary>One channel's guide programmes for the requested window.</summary>
public record ChannelGuideChannelResponseModel(
string Number,
string Name,
List<ChannelGuideProgrammeResponseModel> Programmes);
/// <summary>The JSON channel-guide response: the resolved window plus per-channel programme arrays.</summary>
public record ChannelGuideResponseModel(
DateTimeOffset Start,
DateTimeOffset End,
List<ChannelGuideChannelResponseModel> Channels);
@@ -0,0 +1,98 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Extensions;
public static class PlayoutGuideQueryableExtensions
{
/// <summary>
/// Eager-loads the full playout-item metadata graph needed to render guide programme
/// titles/subtitles/categories/artwork. Shared by the XMLTV cache builder and the JSON guide
/// query so both surfaces see identical data. Callers should apply <c>AsSplitQuery()</c>.
/// </summary>
public static IQueryable<Playout> IncludeGuideMetadata(this IQueryable<Playout> playouts) =>
playouts
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ThenInclude(am => am.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios);
}
@@ -0,0 +1,316 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using DomainChannel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetChannelGuideDataHandlerTests
{
private static readonly DateTime BaseTime = new(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc);
private InMemoryTvContext _db = null!;
private IConfigElementRepository _config = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_config = Substitute.For<IConfigElementRepository>();
// ConfigElementKey has reference equality and each static accessor returns a fresh instance, so we
// match by the generic value type (GetValue<T>) with Arg.Any key. Pin the time zone to UTC so
// projected times are deterministic regardless of the machine/CI time zone, and split block time
// evenly. XmltvDaysToBuild is left unconfigured (falls back to 2) except in the default-window test.
_config.GetValue<XmltvTimeZone>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<XmltvTimeZone>.Some(XmltvTimeZone.Utc));
_config.GetValue<XmltvBlockBehavior>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<XmltvBlockBehavior>.Some(XmltvBlockBehavior.SplitTimeEvenly));
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private GetChannelGuideDataHandler MakeHandler() => new(_db.Factory, _config);
[Test]
public async Task Handle_Should_Only_Include_Visible_Channels()
{
await using (TvContext context = _db.CreateContext())
{
DomainChannel visible = NewChannel("2", "Visible", showInEpg: true);
DomainChannel hidden = NewChannel("3", "Hidden", showInEpg: false);
visible.Playouts = [MakeFloodPlayout(visible, (BaseTime, BaseTime.AddHours(1), 1, "Visible Show"))];
hidden.Playouts = [MakeFloodPlayout(hidden, (BaseTime, BaseTime.AddHours(1), 1, "Hidden Show"))];
context.Channels.AddRange(visible, hidden);
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
result.Channels.Select(c => c.Number).ShouldBe(["2"]);
}
[Test]
public async Task Handle_Should_Order_Channels_By_Decimal_Number()
{
await using (TvContext context = _db.CreateContext())
{
context.Channels.Add(NewChannel("10", "Ten", showInEpg: true));
context.Channels.Add(NewChannel("2", "Two", showInEpg: true));
context.Channels.Add(NewChannel("5.1", "FiveOne", showInEpg: true));
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
// decimal order: 2 < 5.1 < 10 (string order would put "10" first)
result.Channels.Select(c => c.Number).ShouldBe(["2", "5.1", "10"]);
}
[Test]
public async Task Handle_Should_Filter_Programmes_To_Requested_Window()
{
await using (TvContext context = _db.CreateContext())
{
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
channel.Playouts =
[
MakeFloodPlayout(
channel,
(BaseTime, BaseTime.AddHours(1), 1, "Before Window"),
(BaseTime.AddHours(10), BaseTime.AddHours(11), 2, "In Window"))
];
context.Channels.Add(channel);
await context.SaveChangesAsync();
}
// window starts after the first programme has finished
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime.AddHours(5), BaseTime.AddHours(20)),
CancellationToken.None);
List<ChannelGuideProgrammeResponseModel> programmes = result.Channels.Single().Programmes;
programmes.Select(p => p.Title).ShouldBe(["In Window"]);
}
[Test]
public async Task Handle_Should_Passthrough_Custom_Title_And_Null_SubTitle()
{
await using (TvContext context = _db.CreateContext())
{
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
PlayoutItem item = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Ignored");
item.CustomTitle = "Custom Title";
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [item])];
context.Channels.Add(channel);
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single();
programme.Title.ShouldBe("Custom Title");
programme.SubTitle.ShouldBeNull();
programme.Category.ShouldBe("Movie");
}
[Test]
public async Task Handle_Should_Merge_Leading_PreRoll_Filler_Into_Following_Programme()
{
await using (TvContext context = _db.CreateContext())
{
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
PlayoutItem preRoll = MakeItem(BaseTime, BaseTime.AddMinutes(5), guideGroup: 1, movieTitle: "Bumper");
preRoll.FillerKind = FillerKind.PreRoll;
PlayoutItem content = MakeItem(BaseTime.AddMinutes(5), BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Feature");
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [preRoll, content])];
context.Channels.Add(channel);
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single();
// filler is merged: the programme starts at the pre-roll start but displays the feature metadata
programme.Title.ShouldBe("Feature");
programme.Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero));
programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero));
programme.FillerKind.ShouldBe(FillerKind.None);
}
[Test]
public async Task Handle_Should_Split_Block_Window_Evenly_Across_Content_Items()
{
await using (TvContext context = _db.CreateContext())
{
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
PlayoutItem one = MakeItem(BaseTime, BaseTime.AddMinutes(20), guideGroup: 7, movieTitle: "One");
PlayoutItem two = MakeItem(BaseTime.AddMinutes(20), BaseTime.AddMinutes(40), guideGroup: 7, movieTitle: "Two");
foreach (PlayoutItem item in new[] { one, two })
{
item.GuideStart = BaseTime;
item.GuideFinish = BaseTime.AddHours(1);
}
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Block, [one, two])];
context.Channels.Add(channel);
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
List<ChannelGuideProgrammeResponseModel> programmes = result.Channels.Single().Programmes;
programmes.Count.ShouldBe(2);
// 1-hour guide window split evenly across the 2 content items -> 30 minutes each
programmes[0].Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero));
programmes[0].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero));
programmes[1].Start.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero));
programmes[1].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero));
}
[Test]
public async Task Handle_Should_Default_Start_To_Now_And_End_To_DaysToBuild()
{
_config.GetValue<int>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(3));
DateTimeOffset before = DateTimeOffset.UtcNow;
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(null, null),
CancellationToken.None);
DateTimeOffset after = DateTimeOffset.UtcNow;
result.Start.ShouldBeInRange(before, after);
(result.End - result.Start).ShouldBe(TimeSpan.FromDays(3));
}
[Test]
public async Task Handle_Should_Shift_Mirror_Channel_Programmes_By_PlayoutOffset_Without_Mutating_Source_Items()
{
PlayoutItem sourceItem = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Live Show");
var playoutOffset = TimeSpan.FromHours(3);
await using (TvContext context = _db.CreateContext())
{
DomainChannel source = NewChannel("1", "Source", showInEpg: false);
source.Playouts = [MakePlayout(source, PlayoutScheduleKind.Classic, [sourceItem])];
DomainChannel mirror = NewChannel("2", "Mirror", showInEpg: true);
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
mirror.MirrorSourceChannel = source;
mirror.PlayoutOffset = playoutOffset;
context.Channels.AddRange(source, mirror);
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
ChannelGuideProgrammeResponseModel programme = result.Channels.Single(c => c.Number == "2").Programmes.Single();
programme.Title.ShouldBe("Live Show");
programme.Start.ShouldBe(new DateTimeOffset(BaseTime.Add(playoutOffset), TimeSpan.Zero));
programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1).Add(playoutOffset), TimeSpan.Zero));
// the WithPlayoutOffset copy fix: applying the mirror offset must not mutate the source playout's
// own (shared, AsNoTracking) PlayoutItem entities in place.
sourceItem.Start.ShouldBe(BaseTime);
sourceItem.Finish.ShouldBe(BaseTime.AddHours(1));
}
[Test]
public async Task Handle_Should_Return_Empty_Programmes_For_Channel_Without_Playout()
{
await using (TvContext context = _db.CreateContext())
{
context.Channels.Add(NewChannel("2", "Two", showInEpg: true));
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
result.Channels.Single().Programmes.ShouldBeEmpty();
}
// --- seeding helpers ---
private static Playout MakeFloodPlayout(
DomainChannel channel,
params (DateTime Start, DateTime Finish, int GuideGroup, string Title)[] items) =>
MakePlayout(
channel,
PlayoutScheduleKind.Classic,
items.Select(i => MakeItem(i.Start, i.Finish, i.GuideGroup, i.Title)).ToList());
private static Playout MakePlayout(
DomainChannel channel,
PlayoutScheduleKind scheduleKind,
List<PlayoutItem> items) =>
new()
{
Channel = channel,
ScheduleKind = scheduleKind,
ScheduleFile = string.Empty,
Items = items
};
private static PlayoutItem MakeItem(
DateTime start,
DateTime finish,
int guideGroup,
string movieTitle) =>
new()
{
Start = start,
Finish = finish,
GuideGroup = guideGroup,
FillerKind = FillerKind.None,
MediaItem = new Movie
{
MovieMetadata = [new MovieMetadata { Title = movieTitle }],
MediaVersions = []
}
};
private static DomainChannel NewChannel(string number, string name, bool showInEpg) =>
new(Guid.NewGuid())
{
Number = number,
Name = name,
Group = "ErsatzTV",
Categories = string.Empty,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous,
ShowInEpg = showInEpg,
Playouts = []
};
}
@@ -6,6 +6,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
@@ -126,6 +127,35 @@ public class ChannelControllerTests
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetGuide_Should_Map_Query_And_Return_Model()
{
var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var end = new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero);
var model = new ChannelGuideResponseModel(start, end, []);
_mediator.Send(Arg.Any<GetChannelGuideData>(), Arg.Any<CancellationToken>()).Returns(model);
ChannelGuideResponseModel result = await _controller.GetGuide(start, end, CancellationToken.None);
result.ShouldBe(model);
await _mediator.Received(1).Send(
Arg.Is<GetChannelGuideData>(q => q.Start == start && q.End == end),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetGuide_Should_Pass_Null_Bounds_Through()
{
var model = new ChannelGuideResponseModel(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, []);
_mediator.Send(Arg.Any<GetChannelGuideData>(), Arg.Any<CancellationToken>()).Returns(model);
await _controller.GetGuide(null, null, CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetChannelGuideData>(q => q.Start == null && q.End == null),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
@@ -22,6 +22,20 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
[EndpointGroupName("general")]
public async Task<List<ChannelResponseModel>> GetAll() => await mediator.Send(new GetAllChannelsForApi());
[HttpGet("/api/guide")]
[Tags("Channels")]
[EndpointSummary("Get the JSON channel guide (EPG)")]
[EndpointDescription(
"Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now, " +
"and end defaults to start plus the configured XmltvDaysToBuild window.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelGuideResponseModel), StatusCodes.Status200OK)]
public async Task<ChannelGuideResponseModel> GetGuide(
[FromQuery] DateTimeOffset? start,
[FromQuery] DateTimeOffset? end,
CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
[Tags("Channels")]
[EndpointSummary("Get a channel by id")]
+134
View File
@@ -215,6 +215,55 @@
}
}
},
"/api/guide": {
"get": {
"tags": [
"Channels"
],
"summary": "Get the JSON channel guide (EPG)",
"description": "Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now, and end defaults to start plus the configured XmltvDaysToBuild window.",
"parameters": [
{
"name": "start",
"in": "query",
"schema": {
"type": "string",
"format": "date-time"
}
},
{
"name": "end",
"in": "query",
"schema": {
"type": "string",
"format": "date-time"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ChannelGuideResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChannelGuideResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ChannelGuideResponseModel"
}
}
}
}
}
}
},
"/api/channels/{id}": {
"get": {
"tags": [
@@ -3587,6 +3636,91 @@
}
}
},
"ChannelGuideChannelResponseModel": {
"required": [
"number",
"name",
"programmes"
],
"type": "object",
"properties": {
"number": {
"type": "string"
},
"name": {
"type": "string"
},
"programmes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ChannelGuideProgrammeResponseModel"
}
}
}
},
"ChannelGuideProgrammeResponseModel": {
"required": [
"start",
"stop",
"title",
"subTitle",
"category",
"fillerKind"
],
"type": "object",
"properties": {
"start": {
"type": "string",
"format": "date-time"
},
"stop": {
"type": "string",
"format": "date-time"
},
"title": {
"type": "string"
},
"subTitle": {
"type": [
"null",
"string"
]
},
"category": {
"type": [
"null",
"string"
]
},
"fillerKind": {
"$ref": "#/components/schemas/FillerKind"
}
}
},
"ChannelGuideResponseModel": {
"required": [
"start",
"end",
"channels"
],
"type": "object",
"properties": {
"start": {
"type": "string",
"format": "date-time"
},
"end": {
"type": "string",
"format": "date-time"
},
"channels": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ChannelGuideChannelResponseModel"
}
}
}
},
"ChannelIdleBehavior": {
"enum": [
"StopOnDisconnect",