diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..54119b593
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,15 @@
+# Codex Instructions
+
+## Verification Commands
+
+Run .NET restore, build, and test commands outside the sandbox by default in this repo. Sandboxed .NET commands can stall on NuGet/package/compiler cache access, while the same commands complete normally with approved unsandboxed execution.
+
+Preferred verification commands:
+
+```bash
+TZ=UTC dotnet restore ErsatzTV.sln -v minimal
+TZ=UTC dotnet build ErsatzTV.sln --no-restore -v minimal
+TZ=UTC dotnet test ErsatzTV.sln --no-build -v minimal
+```
+
+Use scoped escalated execution for these commands rather than first trying a sandboxed run.
diff --git a/ErsatzTV.Application/Artworks/Commands/UploadArtwork.cs b/ErsatzTV.Application/Artworks/Commands/UploadArtwork.cs
new file mode 100644
index 000000000..e27c60b37
--- /dev/null
+++ b/ErsatzTV.Application/Artworks/Commands/UploadArtwork.cs
@@ -0,0 +1,13 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Api.Artwork;
+using ErsatzTV.Core.Domain;
+
+namespace ErsatzTV.Application.Artworks;
+
+///
+/// Validates and stores an uploaded image as channel logo or watermark artwork,
+/// landing it in the same on-disk cache the Blazor UI uses (via IImageCache),
+/// so the returned path is equivalent to a Blazor-uploaded image.
+///
+public record UploadArtwork(Stream Stream, string ContentType, ArtworkKind ArtworkKind)
+ : IRequest>;
diff --git a/ErsatzTV.Application/Artworks/Commands/UploadArtworkHandler.cs b/ErsatzTV.Application/Artworks/Commands/UploadArtworkHandler.cs
new file mode 100644
index 000000000..d8623baea
--- /dev/null
+++ b/ErsatzTV.Application/Artworks/Commands/UploadArtworkHandler.cs
@@ -0,0 +1,53 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Api.Artwork;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Interfaces.Images;
+
+namespace ErsatzTV.Application.Artworks;
+
+public class UploadArtworkHandler : IRequestHandler>
+{
+ // png/jpeg/gif/webp are all decoded by SkiaSharp and read by FFmpeg, matching the
+ // formats the Blazor logo/watermark upload already accepts. Format expansion is ersatztv#66.
+ private static readonly System.Collections.Generic.HashSet AcceptedContentTypes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/webp"
+ };
+
+ private readonly IImageCache _imageCache;
+
+ public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
+
+ public async Task> Handle(
+ UploadArtwork request,
+ CancellationToken cancellationToken)
+ {
+ string contentType = (request.ContentType ?? string.Empty).Trim();
+ if (!AcceptedContentTypes.Contains(contentType))
+ {
+ return BaseError.New(
+ $"Unsupported image content type '{contentType}'; supported types are: {string.Join(", ", AcceptedContentTypes)}");
+ }
+
+ Either maybeFileName = await _imageCache.SaveArtworkToCache(
+ request.Stream,
+ request.ArtworkKind);
+
+ return maybeFileName.Map(fileName => new ArtworkUploadResponseModel(
+ BuildPath(request.ArtworkKind, fileName),
+ contentType));
+ }
+
+ // Mirror the on-disk conventions the Blazor editors use so the returned path is a drop-in
+ // for ArtworkContentTypeModel.Path: channel logos are addressed as "iptv/logos/{file}"
+ // (see ChannelEditor.UploadLogo), watermarks by the bare cache file name (see WatermarkEditor).
+ private static string BuildPath(ArtworkKind artworkKind, string fileName) =>
+ artworkKind switch
+ {
+ ArtworkKind.Logo => $"iptv/logos/{fileName}",
+ _ => fileName
+ };
+}
diff --git a/ErsatzTV.Application/Channels/ChannelGuideMetadata.cs b/ErsatzTV.Application/Channels/ChannelGuideMetadata.cs
new file mode 100644
index 000000000..6c4e12e4c
--- /dev/null
+++ b/ErsatzTV.Application/Channels/ChannelGuideMetadata.cs
@@ -0,0 +1,71 @@
+using ErsatzTV.Core.Domain;
+
+namespace ErsatzTV.Application.Channels;
+
+///
+/// Shared programme-metadata projection for guide output. Both the XMLTV cache builder
+/// () and the JSON guide query
+/// () resolve the display title/subtitle/category from a
+/// here so the two representations stay consistent.
+///
+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
+ };
+ }
+
+ ///
+ /// The primary guide category, mirroring the fixed <category> the XMLTV templates
+ /// emit per media kind (Movie / Series / Music). Media kinds without a fixed category return null.
+ ///
+ public static string GetCategory(PlayoutItem playoutItem) =>
+ playoutItem.MediaItem switch
+ {
+ Movie => "Movie",
+ Episode => "Series",
+ MusicVideo => "Music",
+ Song => "Music",
+ _ => null
+ };
+}
diff --git a/ErsatzTV.Application/Channels/ChannelGuideProjector.cs b/ErsatzTV.Application/Channels/ChannelGuideProjector.cs
new file mode 100644
index 000000000..4ece0abb9
--- /dev/null
+++ b/ErsatzTV.Application/Channels/ChannelGuideProjector.cs
@@ -0,0 +1,164 @@
+using ErsatzTV.Application.Configuration;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Domain.Filler;
+
+namespace ErsatzTV.Application.Channels;
+
+///
+/// A single guide programme resolved from one or more s: the
+/// whose metadata is shown, plus the coalesced /
+/// window and whether the originating item carried a custom title.
+///
+public readonly record struct ChannelGuideEntry(
+ PlayoutItem DisplayItem,
+ DateTimeOffset Start,
+ DateTimeOffset Stop,
+ bool HasCustomTitle);
+
+///
+/// Shared guide-group / filler-merge projection. This is the single source of truth for turning a
+/// channel's sorted s into guide programmes; both the XMLTV cache builder
+/// () and the JSON guide query
+/// () consume it so the two representations cannot drift.
+/// The XMLTV path formats /
+/// into the XMLTV timestamp strings; the JSON path returns them (and the display item's
+/// ) directly and lets the UI decide how to render filler.
+///
+public static class ChannelGuideProjector
+{
+ public static IEnumerable Project(
+ PlayoutScheduleKind scheduleKind,
+ IReadOnlyList 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 ProjectFlood(
+ IReadOnlyList 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 ProjectBlock(
+ IReadOnlyList 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;
+ }
+ }
+ }
+}
diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
index 5d0207327..9d50f1201 100644
--- a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
+++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
@@ -129,89 +129,7 @@ public class RefreshChannelDataHandler : IRequestHandler
List 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
item.Finish += playoutOffset;
}
- await WritePlayoutXml(
+ await WriteScheduleXml(
request,
+ playout.ScheduleKind,
floodSorted,
templateContext,
movieTemplate,
@@ -270,8 +189,9 @@ public class RefreshChannelDataHandler : IRequestHandler
item.Finish += playoutOffset;
}
- await WriteBlockPlayoutXml(
+ await WriteScheduleXml(
request,
+ playout.ScheduleKind,
blockSorted,
templateContext,
movieTemplate,
@@ -294,8 +214,9 @@ public class RefreshChannelDataHandler : IRequestHandler
item.Finish += playoutOffset;
}
- await WritePlayoutXml(
+ await WriteScheduleXml(
request,
+ playout.ScheduleKind,
externalJsonSorted,
templateContext,
movieTemplate,
@@ -324,100 +245,9 @@ public class RefreshChannelDataHandler : IRequestHandler
}
}
- private async Task WritePlayoutXml(
- RefreshChannelData request,
- List 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(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 sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
@@ -438,98 +268,36 @@ public class RefreshChannelDataHandler : IRequestHandler
.GetValue(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
XmlMinifier minifier,
XmlWriter xml)
{
- string title = GetTitle(displayItem);
- string subtitle = GetSubtitle(displayItem);
+ string title = ChannelGuideMetadata.GetTitle(displayItem);
+ string subtitle = ChannelGuideMetadata.GetSubtitle(displayItem);
Option maybeTemplateOutput = displayItem.MediaItem switch
{
@@ -1117,51 +885,6 @@ public class RefreshChannelDataHandler : IRequestHandler
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 maybeArtwork = Optional(metadata.Artwork).Flatten()
diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuideData.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuideData.cs
new file mode 100644
index 000000000..19dd89d38
--- /dev/null
+++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuideData.cs
@@ -0,0 +1,10 @@
+using ErsatzTV.Core.Api.Channels;
+
+namespace ErsatzTV.Application.Channels;
+
+///
+/// JSON channel-guide query for the EPG grid. defaults to now and
+/// defaults to now + the configured XmltvDaysToBuild window.
+///
+public record GetChannelGuideData(DateTimeOffset? Start, DateTimeOffset? End)
+ : IRequest;
diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs
new file mode 100644
index 000000000..b4294b068
--- /dev/null
+++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs
@@ -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;
+
+///
+/// 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
+ };
+}
diff --git a/ErsatzTV.Application/Filler/Mapper.cs b/ErsatzTV.Application/Filler/Mapper.cs
index 6a4ad35eb..1b72b5200 100644
--- a/ErsatzTV.Application/Filler/Mapper.cs
+++ b/ErsatzTV.Application/Filler/Mapper.cs
@@ -1,9 +1,13 @@
-using ErsatzTV.Core.Domain.Filler;
+using ErsatzTV.Core.Api.Filler;
+using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Filler;
internal static class Mapper
{
+ internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
+ new(fillerPreset.Id, fillerPreset.Name);
+
internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) =>
new(
fillerPreset.Id,
diff --git a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs
new file mode 100644
index 000000000..3252aae1f
--- /dev/null
+++ b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core.Api.Filler;
+
+namespace ErsatzTV.Application.Filler;
+
+public record GetAllFillerPresetsForApi : IRequest>;
diff --git a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs
new file mode 100644
index 000000000..b0c6edaf6
--- /dev/null
+++ b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs
@@ -0,0 +1,22 @@
+using ErsatzTV.Core.Api.Filler;
+using ErsatzTV.Core.Domain.Filler;
+using ErsatzTV.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using static ErsatzTV.Application.Filler.Mapper;
+
+namespace ErsatzTV.Application.Filler;
+
+public class GetAllFillerPresetsForApiHandler(IDbContextFactory dbContextFactory)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ GetAllFillerPresetsForApi request,
+ CancellationToken cancellationToken)
+ {
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ List fillerPresets = await dbContext.FillerPresets
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ return fillerPresets.Map(ProjectToResponseModel).ToList();
+ }
+}
diff --git a/ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApi.cs b/ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApi.cs
new file mode 100644
index 000000000..534ecf73f
--- /dev/null
+++ b/ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApi.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core.Api.Graphics;
+
+namespace ErsatzTV.Application.Graphics;
+
+public record GetAllGraphicsElementsForApi : IRequest>;
diff --git a/ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs b/ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs
new file mode 100644
index 000000000..c6131d4ab
--- /dev/null
+++ b/ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs
@@ -0,0 +1,27 @@
+using ErsatzTV.Core.Api.Graphics;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using static ErsatzTV.Application.Graphics.Mapper;
+
+namespace ErsatzTV.Application.Graphics;
+
+public class GetAllGraphicsElementsForApiHandler(IDbContextFactory dbContextFactory)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ GetAllGraphicsElementsForApi request,
+ CancellationToken cancellationToken)
+ {
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ List graphicsElements = await dbContext.GraphicsElements
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ return graphicsElements
+ .Map(ProjectToViewModel)
+ .OrderBy(e => e.Name == e.FileName)
+ .ThenBy(e => e.Name)
+ .Select(vm => new GraphicsElementResponseModel(vm.Id, vm.Name))
+ .ToList();
+ }
+}
diff --git a/ErsatzTV.Application/Health/Mapper.cs b/ErsatzTV.Application/Health/Mapper.cs
new file mode 100644
index 000000000..5e4ca919b
--- /dev/null
+++ b/ErsatzTV.Application/Health/Mapper.cs
@@ -0,0 +1,24 @@
+using ErsatzTV.Core.Api.Health;
+using ErsatzTV.Core.Health;
+
+namespace ErsatzTV.Application.Health;
+
+internal static class Mapper
+{
+ internal static HealthCheckResponseModel ProjectToResponseModel(HealthCheckResult result) =>
+ new(
+ result.Title,
+ GetStatus(result.Status),
+ result.Message,
+ result.Link.MatchUnsafe(l => l.Link, () => null));
+
+ private static string GetStatus(HealthCheckStatus status) =>
+ status switch
+ {
+ HealthCheckStatus.Pass => "pass",
+ HealthCheckStatus.Fail => "fail",
+ HealthCheckStatus.Warning => "warn",
+ HealthCheckStatus.Info => "info",
+ _ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
+ };
+}
diff --git a/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApi.cs b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApi.cs
new file mode 100644
index 000000000..a3062a2f7
--- /dev/null
+++ b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApi.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core.Api.Health;
+
+namespace ErsatzTV.Application.Health;
+
+public record GetAllHealthCheckResultsForApi : IRequest>;
diff --git a/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApiHandler.cs b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApiHandler.cs
new file mode 100644
index 000000000..334d4cd28
--- /dev/null
+++ b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApiHandler.cs
@@ -0,0 +1,32 @@
+using ErsatzTV.Core.Api.Health;
+using ErsatzTV.Core.Health;
+using static ErsatzTV.Application.Health.Mapper;
+
+namespace ErsatzTV.Application.Health;
+
+public class GetAllHealthCheckResultsForApiHandler
+ : IRequestHandler>
+{
+ private readonly IHealthCheckService _healthCheckService;
+
+ public GetAllHealthCheckResultsForApiHandler(IHealthCheckService healthCheckService) =>
+ _healthCheckService = healthCheckService;
+
+ public async Task> Handle(
+ GetAllHealthCheckResultsForApi request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ List results = await _healthCheckService.PerformHealthChecks(cancellationToken);
+ return results
+ .Filter(r => r.Status != HealthCheckStatus.NotApplicable)
+ .Map(ProjectToResponseModel)
+ .ToList();
+ }
+ catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
+ {
+ return [];
+ }
+ }
+}
diff --git a/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatus.cs b/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatus.cs
new file mode 100644
index 000000000..9d21ed9aa
--- /dev/null
+++ b/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatus.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core.Api.Libraries;
+
+namespace ErsatzTV.Application.Libraries;
+
+public record GetLibraryScanStatus : IRequest>;
diff --git a/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatusHandler.cs b/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatusHandler.cs
new file mode 100644
index 000000000..707d32cf3
--- /dev/null
+++ b/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatusHandler.cs
@@ -0,0 +1,20 @@
+using ErsatzTV.Core.Api.Libraries;
+using ErsatzTV.Core.Interfaces.Metadata;
+using ErsatzTV.Core.Metadata;
+
+namespace ErsatzTV.Application.Libraries;
+
+public class GetLibraryScanStatusHandler(IScannerProxyService scannerProxyService)
+ : IRequestHandler>
+{
+ public Task> Handle(
+ GetLibraryScanStatus request,
+ CancellationToken cancellationToken)
+ {
+ List result = scannerProxyService.GetActiveScans()
+ .Select(scan => new LibraryScanStatusResponseModel(scan.LibraryId, scan.Progress))
+ .ToList();
+
+ return Task.FromResult(result);
+ }
+}
diff --git a/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApi.cs b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApi.cs
new file mode 100644
index 000000000..8753aa9ab
--- /dev/null
+++ b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApi.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core.Api.MediaSources;
+
+namespace ErsatzTV.Application.MediaSources;
+
+public record GetAllMediaSourcesForApi : IRequest>;
diff --git a/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApiHandler.cs b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApiHandler.cs
new file mode 100644
index 000000000..13e007856
--- /dev/null
+++ b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApiHandler.cs
@@ -0,0 +1,148 @@
+#nullable enable
+using Dapper;
+using ErsatzTV.Core.Api.MediaSources;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace ErsatzTV.Application.MediaSources;
+
+public class GetAllMediaSourcesForApiHandler(
+ IDbContextFactory dbContextFactory)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ GetAllMediaSourcesForApi request,
+ CancellationToken cancellationToken)
+ {
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+
+ List mediaSources = await dbContext.MediaSources
+ .AsNoTracking()
+ .Include(s => s.Libraries)
+ .ThenInclude(l => l.Paths)
+ .ToListAsync(cancellationToken);
+
+ Dictionary itemCountsByLibrary = await GetItemCountsByLibrary(dbContext, cancellationToken);
+ Dictionary addressByMediaSourceId = await GetConnectionAddresses(dbContext, cancellationToken);
+
+ var result = new List();
+ foreach (MediaSource mediaSource in mediaSources)
+ {
+ List libraryModels = mediaSource.Libraries
+ .Filter(ShouldIncludeLibrary)
+ .OrderBy(l => l.MediaKind)
+ .ThenBy(l => l.Name)
+ .Map(l => new MediaSourceLibraryResponseModel(
+ l.Id,
+ l.Name,
+ l.MediaKind,
+ l.LastScan,
+ itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
+ .ToList();
+
+ string? address = addressByMediaSourceId.TryGetValue(mediaSource.Id, out string? a) ? a : null;
+
+ result.Add(
+ new MediaSourceResponseModel(
+ mediaSource.Id,
+ GetKind(mediaSource),
+ GetName(mediaSource),
+ address,
+ libraryModels));
+ }
+
+ return result
+ .OrderBy(s => s.Kind == "Local" ? 0 : 1)
+ .ThenBy(s => s.Kind)
+ .ThenBy(s => s.Name)
+ .ToList();
+ }
+
+ private static async Task> GetItemCountsByLibrary(
+ TvContext dbContext,
+ CancellationToken cancellationToken)
+ {
+ IEnumerable counts = await dbContext.Connection.QueryAsync(
+ new CommandDefinition(
+ @"SELECT LP.LibraryId AS LibraryId, COUNT(*) AS Count
+ FROM MediaItem
+ INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
+ GROUP BY LP.LibraryId",
+ cancellationToken: cancellationToken));
+
+ return counts.ToDictionary(c => (int)c.LibraryId, c => (int)c.Count);
+ }
+
+ private static async Task> GetConnectionAddresses(
+ TvContext dbContext,
+ CancellationToken cancellationToken)
+ {
+ var addresses = new Dictionary();
+
+ foreach (PlexMediaSource plex in await dbContext.PlexMediaSources
+ .AsNoTracking()
+ .Include(s => s.Connections)
+ .ToListAsync(cancellationToken))
+ {
+ foreach (PlexConnection connection in Optional(plex.Connections.SingleOrDefault(c => c.IsActive)))
+ {
+ addresses[plex.Id] = connection.Uri;
+ }
+ }
+
+ foreach (JellyfinMediaSource jellyfin in await dbContext.JellyfinMediaSources
+ .AsNoTracking()
+ .Include(s => s.Connections)
+ .ToListAsync(cancellationToken))
+ {
+ foreach (JellyfinConnection connection in jellyfin.Connections.HeadOrNone())
+ {
+ addresses[jellyfin.Id] = connection.Address;
+ }
+ }
+
+ foreach (EmbyMediaSource emby in await dbContext.EmbyMediaSources
+ .AsNoTracking()
+ .Include(s => s.Connections)
+ .ToListAsync(cancellationToken))
+ {
+ foreach (EmbyConnection connection in emby.Connections.HeadOrNone())
+ {
+ addresses[emby.Id] = connection.Address;
+ }
+ }
+
+ return addresses;
+ }
+
+ private static bool ShouldIncludeLibrary(Library library) =>
+ library switch
+ {
+ LocalLibrary => library.Paths.Count > 0,
+ PlexLibrary plex => plex.ShouldSyncItems,
+ JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems,
+ EmbyLibrary emby => emby.ShouldSyncItems,
+ _ => false
+ };
+
+ private static string GetKind(MediaSource mediaSource) =>
+ mediaSource switch
+ {
+ PlexMediaSource => "Plex",
+ JellyfinMediaSource => "Jellyfin",
+ EmbyMediaSource => "Emby",
+ _ => "Local"
+ };
+
+ private static string GetName(MediaSource mediaSource) =>
+ mediaSource switch
+ {
+ PlexMediaSource plex => plex.ServerName,
+ JellyfinMediaSource jellyfin => jellyfin.ServerName,
+ EmbyMediaSource emby => emby.ServerName,
+ _ => "Local"
+ };
+
+ private sealed record LibraryItemCount(long LibraryId, long Count);
+}
diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs
index e47f91433..b765ff7dd 100644
--- a/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs
+++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs
@@ -16,6 +16,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory dbContextFactory
.AsNoTracking()
.Include(p => p.ProgramSchedule)
.Include(p => p.Channel)
+ .Include(p => p.BuildStatus)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
.MapT(p => new PlayoutNameViewModel(
p.Id,
diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs
index bbd48faa0..99ce25574 100644
--- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs
+++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs
@@ -44,6 +44,33 @@ public abstract record ProgramScheduleItemViewModel(
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode)
{
+ ///
+ /// A rough estimate, in wall-clock time, of how long a single pass of this schedule item will play,
+ /// derived from the aggregated playout runtimes of the referenced content.
+ ///
+ /// Semantics by :
+ ///
+ /// - One — the average runtime of one item in the referenced collection.
+ /// -
+ /// Multiple — the average item runtime multiplied by the configured count
+ /// (), or the whole collection runtime for
+ /// . An expression-based (non-integer)
+ /// count cannot be evaluated here and yields null.
+ ///
+ /// - Flood — always null: a flood item fills the remaining time and is unbounded.
+ /// - Duration — the explicit playoutDuration setting on the item.
+ ///
+ ///
+ ///
+ /// null whenever a bounded estimate cannot be produced — an unbounded mode (Flood),
+ /// a referenced collection with no items that have a known positive duration, a Multiple mode other
+ /// than Count/CollectionSize, or a collection type other than
+ /// (smart/multi/playlist/search/rerun/show/season/artist references are not aggregated in this pass).
+ /// Callers should treat a null as "unknown", never as zero.
+ ///
+ ///
+ public TimeSpan? DurationEstimate { get; init; }
+
public string Name => CollectionType switch
{
CollectionType.Collection => Collection?.Name,
diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs
new file mode 100644
index 000000000..0d0fa4623
--- /dev/null
+++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs
@@ -0,0 +1,18 @@
+namespace ErsatzTV.Application.ProgramSchedules;
+
+///
+/// The items of a schedule together with computed runtime estimates. Each item carries its own
+/// (nullable — see that property for
+/// the per-mode semantics), and is the sum of the items that
+/// could be estimated.
+///
+/// The schedule items, each with a nullable DurationEstimate.
+///
+/// The sum of every non-null per-item estimate, i.e. a rough runtime for a single pass through the
+/// estimable items. null when no item in the schedule could be estimated (for example a
+/// schedule made up entirely of Flood items, or of collection types that are not aggregated).
+/// Because unbounded items contribute nothing, this is a lower bound, never an exact schedule length.
+///
+public record ProgramScheduleItemsWithDurationViewModel(
+ List Items,
+ TimeSpan? TotalDurationEstimate);
diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurations.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurations.cs
new file mode 100644
index 000000000..abfa27406
--- /dev/null
+++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurations.cs
@@ -0,0 +1,4 @@
+namespace ErsatzTV.Application.ProgramSchedules;
+
+public record GetProgramScheduleItemsWithDurations(int Id)
+ : IRequest;
diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs
new file mode 100644
index 000000000..72c9a1271
--- /dev/null
+++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs
@@ -0,0 +1,70 @@
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Extensions;
+using ErsatzTV.Core.Interfaces.Repositories;
+using static ErsatzTV.Application.ProgramSchedules.ScheduleItemDurationEstimator;
+
+namespace ErsatzTV.Application.ProgramSchedules;
+
+public class GetProgramScheduleItemsWithDurationsHandler(
+ IMediator mediator,
+ IMediaCollectionRepository mediaCollectionRepository)
+ : IRequestHandler
+{
+ public async Task Handle(
+ GetProgramScheduleItemsWithDurations request,
+ CancellationToken cancellationToken)
+ {
+ List items =
+ await mediator.Send(new GetProgramScheduleItems(request.Id), cancellationToken);
+
+ Dictionary durationsByCollectionId =
+ await AggregateReferencedCollections(items);
+
+ var itemsWithEstimates = items
+ .Map(item => item with { DurationEstimate = Estimate(item, durationsByCollectionId) })
+ .ToList();
+
+ List estimates = itemsWithEstimates
+ .Map(item => Optional(item.DurationEstimate))
+ .Somes()
+ .ToList();
+
+ TimeSpan? total = estimates.Count > 0
+ ? TimeSpan.FromTicks(estimates.Sum(estimate => estimate.Ticks))
+ : null;
+
+ return new ProgramScheduleItemsWithDurationViewModel(itemsWithEstimates, total);
+ }
+
+ // Aggregate MediaVersion.Duration once per distinct referenced collection (not per item). Only plain
+ // collections are resolved here; other reference types are estimated as null (see DurationEstimate docs).
+ private async Task> AggregateReferencedCollections(
+ IReadOnlyList items)
+ {
+ List collectionIds = items
+ .Filter(item => item.CollectionType is CollectionType.Collection && item.Collection is not null)
+ .Filter(item => item.PlayoutMode is PlayoutMode.One or PlayoutMode.Multiple)
+ .Map(item => item.Collection.Id)
+ .Distinct()
+ .ToList();
+
+ var result = new Dictionary();
+ foreach (int collectionId in collectionIds)
+ {
+ List mediaItems = await mediaCollectionRepository.GetItems(collectionId);
+
+ List durations = mediaItems
+ .Map(mediaItem => mediaItem.GetDurationForPlayout())
+ .Filter(duration => duration > TimeSpan.Zero)
+ .ToList();
+
+ if (durations.Count > 0)
+ {
+ var total = TimeSpan.FromTicks(durations.Sum(duration => duration.Ticks));
+ result[collectionId] = new CollectionDuration(total, durations.Count);
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs b/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs
new file mode 100644
index 000000000..9a6a817a3
--- /dev/null
+++ b/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs
@@ -0,0 +1,73 @@
+using System.Globalization;
+using ErsatzTV.Core.Domain;
+
+namespace ErsatzTV.Application.ProgramSchedules;
+
+///
+/// Pure computation of per-item runtime estimates from pre-aggregated collection durations.
+/// Kept separate from the query handler so the (non-trivial) per-mode math can be unit-tested
+/// without a database. See for the
+/// documented semantics this implements.
+///
+internal static class ScheduleItemDurationEstimator
+{
+ ///
+ /// Aggregate runtime of a single referenced collection: the total runtime of every item with a
+ /// known non-zero duration, and how many such items there are.
+ ///
+ public sealed record CollectionDuration(TimeSpan Total, int ItemCount)
+ {
+ public TimeSpan? Average => ItemCount > 0 ? Total / ItemCount : null;
+ }
+
+ ///
+ /// Estimate the runtime of one pass of , or null when no bounded
+ /// estimate is possible. holds aggregates only for the
+ /// plain collections that were resolved; a missing entry yields null.
+ ///
+ public static TimeSpan? Estimate(
+ ProgramScheduleItemViewModel item,
+ IReadOnlyDictionary durationsByCollectionId)
+ {
+ if (item is ProgramScheduleItemDurationViewModel durationItem)
+ {
+ return durationItem.PlayoutDuration;
+ }
+
+ // only plain collections are aggregated in this pass
+ if (item.CollectionType is not CollectionType.Collection || item.Collection is null)
+ {
+ return null;
+ }
+
+ if (!durationsByCollectionId.TryGetValue(item.Collection.Id, out CollectionDuration duration))
+ {
+ return null;
+ }
+
+ return item switch
+ {
+ // one item per pass -> the average item runtime
+ ProgramScheduleItemOneViewModel => duration.Average,
+
+ // a fixed count of items, or the whole collection once
+ ProgramScheduleItemMultipleViewModel multiple => EstimateMultiple(multiple, duration),
+
+ // Flood is an unbounded fill; Duration is handled above from its explicit playoutDuration.
+ _ => null
+ };
+ }
+
+ private static TimeSpan? EstimateMultiple(
+ ProgramScheduleItemMultipleViewModel multiple,
+ CollectionDuration duration) =>
+ multiple.MultipleMode switch
+ {
+ MultipleMode.Count when
+ int.TryParse(multiple.Count, NumberStyles.Integer, CultureInfo.InvariantCulture, out int count)
+ && count > 0
+ && duration.Average is { } average => average * count,
+ MultipleMode.CollectionSize => duration.Total,
+ _ => null
+ };
+}
diff --git a/ErsatzTV.Application/Watermarks/Mapper.cs b/ErsatzTV.Application/Watermarks/Mapper.cs
index 537932caa..4869d2ddc 100644
--- a/ErsatzTV.Application/Watermarks/Mapper.cs
+++ b/ErsatzTV.Application/Watermarks/Mapper.cs
@@ -1,10 +1,14 @@
using ErsatzTV.Application.Artworks;
+using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Watermarks;
internal static class Mapper
{
+ internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
+ new(watermark.Id, watermark.Name);
+
public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) =>
new(
watermark.Id,
diff --git a/ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApi.cs b/ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApi.cs
new file mode 100644
index 000000000..acf3b86a9
--- /dev/null
+++ b/ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApi.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core.Api.Watermarks;
+
+namespace ErsatzTV.Application.Watermarks;
+
+public record GetAllWatermarksForApi : IRequest>;
diff --git a/ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApiHandler.cs b/ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApiHandler.cs
new file mode 100644
index 000000000..758fe6349
--- /dev/null
+++ b/ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApiHandler.cs
@@ -0,0 +1,22 @@
+using ErsatzTV.Core.Api.Watermarks;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using static ErsatzTV.Application.Watermarks.Mapper;
+
+namespace ErsatzTV.Application.Watermarks;
+
+public class GetAllWatermarksForApiHandler(IDbContextFactory dbContextFactory)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ GetAllWatermarksForApi request,
+ CancellationToken cancellationToken)
+ {
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ List watermarks = await dbContext.ChannelWatermarks
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ return watermarks.Map(ProjectToResponseModel).ToList();
+ }
+}
diff --git a/ErsatzTV.Core/Api/Artwork/ArtworkUploadResponseModel.cs b/ErsatzTV.Core/Api/Artwork/ArtworkUploadResponseModel.cs
new file mode 100644
index 000000000..ee68a3fab
--- /dev/null
+++ b/ErsatzTV.Core/Api/Artwork/ArtworkUploadResponseModel.cs
@@ -0,0 +1,10 @@
+#nullable enable
+namespace ErsatzTV.Core.Api.Artwork;
+
+///
+/// Result of uploading channel logo / watermark artwork via the REST API.
+/// is directly consumable as the Path of an
+/// ArtworkContentTypeModel (e.g. CreateChannel.Logo / channel update),
+/// and carries the stored MIME type.
+///
+public record ArtworkUploadResponseModel(string Path, string ContentType);
diff --git a/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs
new file mode 100644
index 000000000..cc968cff0
--- /dev/null
+++ b/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs
@@ -0,0 +1,25 @@
+#nullable enable
+using ErsatzTV.Core.Domain.Filler;
+
+namespace ErsatzTV.Core.Api.Channels;
+
+/// A single guide programme for the JSON EPG grid.
+public record ChannelGuideProgrammeResponseModel(
+ DateTimeOffset Start,
+ DateTimeOffset Stop,
+ string Title,
+ string? SubTitle,
+ string? Category,
+ FillerKind FillerKind);
+
+/// One channel's guide programmes for the requested window.
+public record ChannelGuideChannelResponseModel(
+ string Number,
+ string Name,
+ List Programmes);
+
+/// The JSON channel-guide response: the resolved window plus per-channel programme arrays.
+public record ChannelGuideResponseModel(
+ DateTimeOffset Start,
+ DateTimeOffset End,
+ List Channels);
diff --git a/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs b/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs
new file mode 100644
index 000000000..a237a0b3b
--- /dev/null
+++ b/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs
@@ -0,0 +1,3 @@
+namespace ErsatzTV.Core.Api.Filler;
+
+public record FillerPresetResponseModel(int Id, string Name);
diff --git a/ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs b/ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs
new file mode 100644
index 000000000..fef16a496
--- /dev/null
+++ b/ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs
@@ -0,0 +1,3 @@
+namespace ErsatzTV.Core.Api.Graphics;
+
+public record GraphicsElementResponseModel(int Id, string Name);
diff --git a/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs b/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs
new file mode 100644
index 000000000..39cf3ae39
--- /dev/null
+++ b/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs
@@ -0,0 +1,8 @@
+#nullable enable
+namespace ErsatzTV.Core.Api.Health;
+
+public record HealthCheckResponseModel(
+ string Title,
+ string Status,
+ string Detail,
+ string? Link);
diff --git a/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs b/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs
new file mode 100644
index 000000000..fec106914
--- /dev/null
+++ b/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs
@@ -0,0 +1,3 @@
+namespace ErsatzTV.Core.Api.Libraries;
+
+public record LibraryScanStatusResponseModel(int LibraryId, decimal Percent);
diff --git a/ErsatzTV.Core/Api/MediaSources/MediaSourceLibraryResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/MediaSourceLibraryResponseModel.cs
new file mode 100644
index 000000000..967853efd
--- /dev/null
+++ b/ErsatzTV.Core/Api/MediaSources/MediaSourceLibraryResponseModel.cs
@@ -0,0 +1,11 @@
+#nullable enable
+using ErsatzTV.Core.Domain;
+
+namespace ErsatzTV.Core.Api.MediaSources;
+
+public record MediaSourceLibraryResponseModel(
+ int Id,
+ string Name,
+ LibraryMediaKind MediaKind,
+ DateTime? LastScan,
+ int ItemCount);
diff --git a/ErsatzTV.Core/Api/MediaSources/MediaSourceResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/MediaSourceResponseModel.cs
new file mode 100644
index 000000000..2ee99eb43
--- /dev/null
+++ b/ErsatzTV.Core/Api/MediaSources/MediaSourceResponseModel.cs
@@ -0,0 +1,9 @@
+#nullable enable
+namespace ErsatzTV.Core.Api.MediaSources;
+
+public record MediaSourceResponseModel(
+ int Id,
+ string Kind,
+ string Name,
+ string? ConnectionAddress,
+ List Libraries);
diff --git a/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs
new file mode 100644
index 000000000..be6249ef1
--- /dev/null
+++ b/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs
@@ -0,0 +1,5 @@
+namespace ErsatzTV.Core.Api.Playouts;
+
+public record PagedPlayoutItemsResponseModel(
+ int TotalCount,
+ List Page);
diff --git a/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs
new file mode 100644
index 000000000..5473e67d2
--- /dev/null
+++ b/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs
@@ -0,0 +1,5 @@
+namespace ErsatzTV.Core.Api.Playouts;
+
+public record PagedPlayoutsResponseModel(
+ int TotalCount,
+ List Page);
diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs
new file mode 100644
index 000000000..5410c9d23
--- /dev/null
+++ b/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs
@@ -0,0 +1,6 @@
+namespace ErsatzTV.Core.Api.Playouts;
+
+public record PlayoutBuildStatusResponseModel(
+ DateTimeOffset LastBuild,
+ bool Success,
+ string Message);
diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs
new file mode 100644
index 000000000..ab9c7104e
--- /dev/null
+++ b/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs
@@ -0,0 +1,10 @@
+using ErsatzTV.Core.Domain.Filler;
+
+namespace ErsatzTV.Core.Api.Playouts;
+
+public record PlayoutItemResponseModel(
+ string Title,
+ DateTimeOffset Start,
+ DateTimeOffset Finish,
+ string Duration,
+ FillerKind? FillerKind);
diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs
new file mode 100644
index 000000000..b7caa66ce
--- /dev/null
+++ b/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs
@@ -0,0 +1,13 @@
+#nullable enable
+using ErsatzTV.Core.Domain;
+
+namespace ErsatzTV.Core.Api.Playouts;
+
+public record PlayoutListItemResponseModel(
+ int Id,
+ string ChannelNumber,
+ string ChannelName,
+ PlayoutScheduleKind ScheduleKind,
+ string ScheduleName,
+ TimeSpan? DailyRebuildTime,
+ PlayoutBuildStatusResponseModel? BuildStatus);
diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs
index a80fd6ed0..cd382a278 100644
--- a/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs
+++ b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs
@@ -1,3 +1,4 @@
+#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Playouts;
@@ -9,8 +10,9 @@ public record PlayoutResponseModel(
string ChannelNumber,
ChannelPlayoutMode PlayoutMode,
string ScheduleName,
- string ScheduleFile,
- TimeSpan? DailyRebuildTime)
+ string? ScheduleFile,
+ TimeSpan? DailyRebuildTime,
+ PlayoutBuildStatusResponseModel? BuildStatus)
{
public static PlayoutResponseModel From(
int id,
@@ -19,8 +21,9 @@ public record PlayoutResponseModel(
string channelNumber,
ChannelPlayoutMode playoutMode,
string scheduleName,
- string scheduleFile,
- TimeSpan? dailyRebuildTime) =>
+ string? scheduleFile,
+ TimeSpan? dailyRebuildTime,
+ PlayoutBuildStatusResponseModel? buildStatus) =>
new(
id,
scheduleKind,
@@ -29,5 +32,6 @@ public record PlayoutResponseModel(
playoutMode,
scheduleName,
scheduleFile,
- dailyRebuildTime);
+ dailyRebuildTime,
+ buildStatus);
}
diff --git a/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs b/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs
new file mode 100644
index 000000000..656e5b550
--- /dev/null
+++ b/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs
@@ -0,0 +1,3 @@
+namespace ErsatzTV.Core.Api.Watermarks;
+
+public record WatermarkResponseModel(int Id, string Name);
diff --git a/ErsatzTV.Core/Interfaces/Metadata/IScannerProxyService.cs b/ErsatzTV.Core/Interfaces/Metadata/IScannerProxyService.cs
index 556f46720..040841d1b 100644
--- a/ErsatzTV.Core/Interfaces/Metadata/IScannerProxyService.cs
+++ b/ErsatzTV.Core/Interfaces/Metadata/IScannerProxyService.cs
@@ -1,3 +1,5 @@
+using ErsatzTV.Core.Metadata;
+
namespace ErsatzTV.Core.Interfaces.Metadata;
public interface IScannerProxyService
@@ -7,4 +9,5 @@ public interface IScannerProxyService
Task Progress(Guid scanId, decimal percentComplete);
bool IsActive(Guid scanId);
Option GetProgress(int libraryId);
+ IReadOnlyList GetActiveScans();
}
diff --git a/ErsatzTV.Core/Metadata/ScannerProxyService.cs b/ErsatzTV.Core/Metadata/ScannerProxyService.cs
index 561116132..8b6b4fed6 100644
--- a/ErsatzTV.Core/Metadata/ScannerProxyService.cs
+++ b/ErsatzTV.Core/Metadata/ScannerProxyService.cs
@@ -49,4 +49,7 @@ public class ScannerProxyService(IMediator mediator) : IScannerProxyService
public Option GetProgress(int libraryId) => _activeLibraries.TryGetValue(libraryId, out decimal progress)
? progress
: Option.None;
+
+ public IReadOnlyList GetActiveScans() =>
+ _activeLibraries.Select(kvp => new LibraryScanProgress(kvp.Key, kvp.Value)).ToList();
}
diff --git a/ErsatzTV.Infrastructure/Extensions/PlayoutGuideQueryableExtensions.cs b/ErsatzTV.Infrastructure/Extensions/PlayoutGuideQueryableExtensions.cs
new file mode 100644
index 000000000..68c14080d
--- /dev/null
+++ b/ErsatzTV.Infrastructure/Extensions/PlayoutGuideQueryableExtensions.cs
@@ -0,0 +1,98 @@
+using ErsatzTV.Core.Domain;
+using Microsoft.EntityFrameworkCore;
+
+namespace ErsatzTV.Infrastructure.Extensions;
+
+public static class PlayoutGuideQueryableExtensions
+{
+ ///
+ /// 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 AsSplitQuery().
+ ///
+ public static IQueryable IncludeGuideMetadata(this IQueryable 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);
+}
diff --git a/ErsatzTV.Tests/Application/Artworks/UploadArtworkHandlerTests.cs b/ErsatzTV.Tests/Application/Artworks/UploadArtworkHandlerTests.cs
new file mode 100644
index 000000000..3a4792a90
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Artworks/UploadArtworkHandlerTests.cs
@@ -0,0 +1,87 @@
+using ErsatzTV.Application.Artworks;
+using ErsatzTV.Core;
+using ErsatzTV.Core.Api.Artwork;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Interfaces.Images;
+using LanguageExt;
+using NSubstitute;
+using NUnit.Framework;
+using Shouldly;
+using static LanguageExt.Prelude;
+
+namespace ErsatzTV.Tests.Application.Artworks;
+
+[TestFixture]
+public class UploadArtworkHandlerTests
+{
+ private IImageCache _imageCache = null!;
+ private UploadArtworkHandler _handler = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _imageCache = Substitute.For();
+ _handler = new UploadArtworkHandler(_imageCache);
+ }
+
+ [Test]
+ public async Task Handle_Should_Return_Logo_Path_With_Iptv_Logos_Prefix()
+ {
+ _imageCache.SaveArtworkToCache(Arg.Any(), ArtworkKind.Logo)
+ .Returns(Right("abc123.png"));
+
+ using var stream = new MemoryStream();
+ Either result =
+ await _handler.Handle(new UploadArtwork(stream, "image/png", ArtworkKind.Logo), CancellationToken.None);
+
+ ArtworkUploadResponseModel response = RightOf(result);
+ response.Path.ShouldBe("iptv/logos/abc123.png");
+ response.ContentType.ShouldBe("image/png");
+ }
+
+ [Test]
+ public async Task Handle_Should_Return_Bare_File_Name_For_Watermark()
+ {
+ _imageCache.SaveArtworkToCache(Arg.Any(), ArtworkKind.Watermark)
+ .Returns(Right("def456.webp"));
+
+ using var stream = new MemoryStream();
+ Either result = await _handler.Handle(
+ new UploadArtwork(stream, "image/webp", ArtworkKind.Watermark),
+ CancellationToken.None);
+
+ RightOf(result).Path.ShouldBe("def456.webp");
+ }
+
+ [Test]
+ public async Task Handle_Should_Reject_Unsupported_Content_Type()
+ {
+ using var stream = new MemoryStream();
+ Either result = await _handler.Handle(
+ new UploadArtwork(stream, "image/bmp", ArtworkKind.Logo),
+ CancellationToken.None);
+
+ LeftOf(result).Value.ShouldContain("Unsupported image content type");
+ await _imageCache.DidNotReceive().SaveArtworkToCache(Arg.Any(), Arg.Any());
+ }
+
+ [Test]
+ public async Task Handle_Should_Propagate_Cache_Save_Failure()
+ {
+ _imageCache.SaveArtworkToCache(Arg.Any(), ArtworkKind.Logo)
+ .Returns(Left(BaseError.New("disk full")));
+
+ using var stream = new MemoryStream();
+ Either result = await _handler.Handle(
+ new UploadArtwork(stream, "image/png", ArtworkKind.Logo),
+ CancellationToken.None);
+
+ LeftOf(result).Value.ShouldBe("disk full");
+ }
+
+ private static TR RightOf(Either either) =>
+ either.Match(Right: v => v, Left: e => throw new AssertionException($"Expected Right, got Left: {e.Value}"));
+
+ private static BaseError LeftOf(Either either) =>
+ either.Match(Right: _ => throw new AssertionException("Expected Left, got Right"), Left: e => e);
+}
diff --git a/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs
new file mode 100644
index 000000000..806c7a64d
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs
@@ -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();
+
+ // ConfigElementKey has reference equality and each static accessor returns a fresh instance, so we
+ // match by the generic value type (GetValue) 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(Arg.Any(), Arg.Any())
+ .Returns(Option.Some(XmltvTimeZone.Utc));
+ _config.GetValue(Arg.Any(), Arg.Any())
+ .Returns(Option.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 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 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(Arg.Any(), Arg.Any())
+ .Returns(Option.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 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 = []
+ };
+}
diff --git a/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs b/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs
new file mode 100644
index 000000000..6c810cffd
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs
@@ -0,0 +1,63 @@
+using ErsatzTV.Application.Filler;
+using ErsatzTV.Core.Api.Filler;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Domain.Filler;
+using ErsatzTV.Infrastructure.Data;
+using ErsatzTV.Tests.Support;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Application.Filler;
+
+[TestFixture]
+public class FillerPresetHandlerTests
+{
+ private InMemoryTvContext _db = null!;
+
+ [SetUp]
+ public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
+
+ [TearDown]
+ public async Task TearDown() => await _db.DisposeAsync();
+
+ [Test]
+ public async Task GetAllFillerPresetsForApi_Should_Return_All_Presets()
+ {
+ await SeedPreset(1, "Intro");
+ await SeedPreset(2, "Outro");
+
+ var handler = new GetAllFillerPresetsForApiHandler(_db.Factory);
+
+ List result =
+ await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None);
+
+ result.Count.ShouldBe(2);
+ result.ShouldContain(new FillerPresetResponseModel(1, "Intro"));
+ result.ShouldContain(new FillerPresetResponseModel(2, "Outro"));
+ }
+
+ [Test]
+ public async Task GetAllFillerPresetsForApi_Should_Return_Empty_List_When_None_Exist()
+ {
+ var handler = new GetAllFillerPresetsForApiHandler(_db.Factory);
+
+ List result =
+ await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None);
+
+ result.ShouldBeEmpty();
+ }
+
+ private async Task SeedPreset(int id, string name)
+ {
+ await using TvContext context = _db.CreateContext();
+ context.FillerPresets.Add(new FillerPreset
+ {
+ Id = id,
+ Name = name,
+ FillerKind = FillerKind.PreRoll,
+ FillerMode = FillerMode.Duration,
+ CollectionType = CollectionType.Collection
+ });
+ await context.SaveChangesAsync();
+ }
+}
diff --git a/ErsatzTV.Tests/Application/Graphics/GraphicsElementHandlerTests.cs b/ErsatzTV.Tests/Application/Graphics/GraphicsElementHandlerTests.cs
new file mode 100644
index 000000000..d68fa1ca0
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Graphics/GraphicsElementHandlerTests.cs
@@ -0,0 +1,77 @@
+using ErsatzTV.Application.Graphics;
+using ErsatzTV.Core.Api.Graphics;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Infrastructure.Data;
+using ErsatzTV.Tests.Support;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Application.Graphics;
+
+[TestFixture]
+public class GraphicsElementHandlerTests
+{
+ private InMemoryTvContext _db = null!;
+
+ [SetUp]
+ public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
+
+ [TearDown]
+ public async Task TearDown() => await _db.DisposeAsync();
+
+ [Test]
+ public async Task GetAllGraphicsElementsForApi_Should_Return_All_Elements()
+ {
+ await SeedElement(1, "watermark.png", GraphicsElementKind.Image, "Custom Watermark");
+ await SeedElement(2, "clock.png", GraphicsElementKind.Image, string.Empty);
+
+ var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
+
+ List result =
+ await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
+
+ result.Count.ShouldBe(2);
+ result.Select(e => e.Id).ShouldBe([1, 2]);
+ }
+
+ [Test]
+ public async Task GetAllGraphicsElementsForApi_Should_Order_Named_Elements_Before_Unnamed()
+ {
+ // unnamed element's projected Name equals its FileName -> should sort after named elements
+ await SeedElement(1, "aaa.png", GraphicsElementKind.Image, string.Empty);
+ await SeedElement(2, "zzz.png", GraphicsElementKind.Image, "A Custom Name");
+
+ var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
+
+ List result =
+ await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
+
+ result.Count.ShouldBe(2);
+ result[0].Id.ShouldBe(2);
+ result[1].Id.ShouldBe(1);
+ }
+
+ [Test]
+ public async Task GetAllGraphicsElementsForApi_Should_Return_Empty_List_When_None_Exist()
+ {
+ var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
+
+ List result =
+ await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
+
+ result.ShouldBeEmpty();
+ }
+
+ private async Task SeedElement(int id, string path, GraphicsElementKind kind, string name)
+ {
+ await using TvContext context = _db.CreateContext();
+ context.GraphicsElements.Add(new GraphicsElement
+ {
+ Id = id,
+ Path = path,
+ Name = name,
+ Kind = kind
+ });
+ await context.SaveChangesAsync();
+ }
+}
diff --git a/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs b/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs
new file mode 100644
index 000000000..d0e9a0ff4
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs
@@ -0,0 +1,111 @@
+using ErsatzTV.Application.Health;
+using ErsatzTV.Core.Api.Health;
+using ErsatzTV.Core.Health;
+using LanguageExt;
+using NSubstitute;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Application.Health;
+
+[TestFixture]
+public class GetAllHealthCheckResultsForApiHandlerTests
+{
+ private IHealthCheckService _healthCheckService = null!;
+ private GetAllHealthCheckResultsForApiHandler _handler = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _healthCheckService = Substitute.For();
+ _handler = new GetAllHealthCheckResultsForApiHandler(_healthCheckService);
+ }
+
+ [Test]
+ public async Task Should_Map_Status_Codes_To_Lowercase_Strings()
+ {
+ var results = new List
+ {
+ new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option.None),
+ new("Fail Check", HealthCheckStatus.Fail, "broken", "bad", Option.None),
+ new("Warn Check", HealthCheckStatus.Warning, "watch out", "warn", Option.None),
+ new("Info Check", HealthCheckStatus.Info, "fyi", "info", Option.None)
+ };
+
+ _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results);
+
+ List response =
+ await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
+
+ response.Count.ShouldBe(4);
+ response.Select(r => r.Status).ShouldBe(["pass", "fail", "warn", "info"]);
+ }
+
+ [Test]
+ public async Task Should_Filter_Out_NotApplicable_Results()
+ {
+ var results = new List
+ {
+ new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option.None),
+ new("NA Check", HealthCheckStatus.NotApplicable, "skip", "skip", Option.None)
+ };
+
+ _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results);
+
+ List response =
+ await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
+
+ response.Count.ShouldBe(1);
+ response[0].Title.ShouldBe("Pass Check");
+ }
+
+ [Test]
+ public async Task Should_Include_Link_When_Present()
+ {
+ var results = new List
+ {
+ new(
+ "Linked Check",
+ HealthCheckStatus.Warning,
+ "detail message",
+ "brief",
+ Option.Some(new HealthCheckLink("https://example.com/docs")))
+ };
+
+ _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results);
+
+ List response =
+ await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
+
+ response[0].Link.ShouldBe("https://example.com/docs");
+ response[0].Detail.ShouldBe("detail message");
+ }
+
+ [Test]
+ public async Task Should_Have_Null_Link_When_Absent()
+ {
+ var results = new List
+ {
+ new("No Link Check", HealthCheckStatus.Pass, "detail", "brief", Option.None)
+ };
+
+ _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results);
+
+ List response =
+ await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
+
+ response[0].Link.ShouldBeNull();
+ }
+
+ [Test]
+ public async Task Should_Return_Empty_List_On_Cancellation()
+ {
+ _healthCheckService.PerformHealthChecks(Arg.Any())
+ .Returns>>(_ => throw new TaskCanceledException());
+
+ List response =
+ await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
+
+ response.ShouldBeEmpty();
+ }
+}
diff --git a/ErsatzTV.Tests/Application/Libraries/GetLibraryScanStatusHandlerTests.cs b/ErsatzTV.Tests/Application/Libraries/GetLibraryScanStatusHandlerTests.cs
new file mode 100644
index 000000000..1e062f32d
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Libraries/GetLibraryScanStatusHandlerTests.cs
@@ -0,0 +1,32 @@
+using ErsatzTV.Application.Libraries;
+using ErsatzTV.Core.Api.Libraries;
+using ErsatzTV.Core.Metadata;
+using MediatR;
+using NSubstitute;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Application.Libraries;
+
+[TestFixture]
+public class GetLibraryScanStatusHandlerTests
+{
+ [Test]
+ public async Task Handle_Should_Return_Active_Scans()
+ {
+ var mediator = Substitute.For();
+ var scannerProxyService = new ScannerProxyService(mediator);
+ Guid scanId = scannerProxyService.StartScan(42)
+ .Match(
+ Some: id => id,
+ None: () => throw new AssertionException("Expected scan to start"));
+ await scannerProxyService.Progress(scanId, 62.5m);
+
+ var handler = new GetLibraryScanStatusHandler(scannerProxyService);
+
+ List result =
+ await handler.Handle(new GetLibraryScanStatus(), CancellationToken.None);
+
+ result.ShouldBe([new LibraryScanStatusResponseModel(42, 62.5m)]);
+ }
+}
diff --git a/ErsatzTV.Tests/Application/MediaSources/GetAllMediaSourcesForApiHandlerTests.cs b/ErsatzTV.Tests/Application/MediaSources/GetAllMediaSourcesForApiHandlerTests.cs
new file mode 100644
index 000000000..85f421a53
--- /dev/null
+++ b/ErsatzTV.Tests/Application/MediaSources/GetAllMediaSourcesForApiHandlerTests.cs
@@ -0,0 +1,179 @@
+using ErsatzTV.Application.MediaSources;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Infrastructure.Data;
+using ErsatzTV.Tests.Support;
+using Microsoft.EntityFrameworkCore;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Application.MediaSources;
+
+[TestFixture]
+public class GetAllMediaSourcesForApiHandlerTests
+{
+ private InMemoryTvContext _db = null!;
+
+ [SetUp]
+ public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
+
+ [TearDown]
+ public async Task TearDown() => await _db.DisposeAsync();
+
+ [Test]
+ public async Task Handle_Should_Group_Configured_Libraries_By_Source_With_Item_Counts()
+ {
+ await SeedMediaSources();
+ var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
+
+ var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
+
+ result.Count.ShouldBe(4);
+
+ result[0].Kind.ShouldBe("Local");
+ result[0].Name.ShouldBe("Local");
+ result[0].ConnectionAddress.ShouldBeNull();
+ result[0].Libraries.Single().Name.ShouldBe("Local Movies");
+ result[0].Libraries.Single().LastScan.ShouldBe(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc));
+ result[0].Libraries.Single().ItemCount.ShouldBe(2);
+
+ result[1].Kind.ShouldBe("Emby");
+ result[1].Name.ShouldBe("Emby Server");
+ result[1].ConnectionAddress.ShouldBe("http://emby.local");
+ result[1].Libraries.Single().Name.ShouldBe("Emby Shows");
+ result[1].Libraries.Single().ItemCount.ShouldBe(1);
+
+ result[2].Kind.ShouldBe("Jellyfin");
+ result[2].Name.ShouldBe("Jellyfin Server");
+ result[2].ConnectionAddress.ShouldBe("http://jellyfin.local");
+ result[2].Libraries.ShouldBeEmpty();
+
+ result[3].Kind.ShouldBe("Plex");
+ result[3].Name.ShouldBe("Plex Server");
+ result[3].ConnectionAddress.ShouldBe("http://plex.local");
+ result[3].Libraries.Single().Name.ShouldBe("Plex Movies");
+ result[3].Libraries.Single().ItemCount.ShouldBe(0);
+ }
+
+ [Test]
+ public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries()
+ {
+ await SeedMediaSources();
+ var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
+
+ var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
+
+ result.SelectMany(s => s.Libraries).Select(l => l.Name)
+ .ShouldNotContain("Empty Local");
+ result.SelectMany(s => s.Libraries).Select(l => l.Name)
+ .ShouldNotContain("Disabled Jellyfin");
+ }
+
+ private async Task SeedMediaSources()
+ {
+ await using TvContext context = _db.CreateContext();
+
+ var localSource = new LocalMediaSource
+ {
+ Libraries =
+ [
+ new LocalLibrary
+ {
+ Name = "Local Movies",
+ MediaKind = LibraryMediaKind.Movies,
+ LastScan = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
+ Paths = [MakePath("/media/movies", 2)]
+ },
+ new LocalLibrary
+ {
+ Name = "Empty Local",
+ MediaKind = LibraryMediaKind.Shows,
+ Paths = []
+ }
+ ]
+ };
+
+ var plexSource = new PlexMediaSource
+ {
+ ServerName = "Plex Server",
+ ProductVersion = "1",
+ Platform = "Linux",
+ PlatformVersion = "1",
+ ClientIdentifier = "plex",
+ Connections = [new PlexConnection { IsActive = true, Uri = "http://plex.local" }],
+ PathReplacements = [],
+ Libraries =
+ [
+ new PlexLibrary
+ {
+ Name = "Plex Movies",
+ MediaKind = LibraryMediaKind.Movies,
+ Key = "1",
+ ShouldSyncItems = true,
+ Paths = []
+ }
+ ]
+ };
+
+ var embySource = new EmbyMediaSource
+ {
+ ServerName = "Emby Server",
+ OperatingSystem = "Linux",
+ Connections = [new EmbyConnection { Address = "http://emby.local" }],
+ PathReplacements = [],
+ Libraries =
+ [
+ new EmbyLibrary
+ {
+ Name = "Emby Shows",
+ MediaKind = LibraryMediaKind.Shows,
+ ItemId = "emby-shows",
+ ShouldSyncItems = true,
+ PathInfos = [],
+ Paths = [MakePath("/emby/shows", 1)]
+ }
+ ]
+ };
+
+ var jellyfinSource = new JellyfinMediaSource
+ {
+ ServerName = "Jellyfin Server",
+ OperatingSystem = "Linux",
+ Connections = [new JellyfinConnection { Address = "http://jellyfin.local" }],
+ PathReplacements = [],
+ Libraries =
+ [
+ new JellyfinLibrary
+ {
+ Name = "Disabled Jellyfin",
+ MediaKind = LibraryMediaKind.Movies,
+ ItemId = "jellyfin-movies",
+ ShouldSyncItems = false,
+ PathInfos = [],
+ Paths = [MakePath("/jellyfin/movies", 1)]
+ }
+ ]
+ };
+
+ context.MediaSources.AddRange(localSource, plexSource, embySource, jellyfinSource);
+ await context.SaveChangesAsync();
+ context.ChangeTracker.Clear();
+ }
+
+ private static LibraryPath MakePath(string path, int mediaItemCount) =>
+ new()
+ {
+ Path = path,
+ LibraryFolders = [],
+ MediaItems = Enumerable.Range(0, mediaItemCount)
+ .Select(_ => new Movie
+ {
+ MovieMetadata = [],
+ MediaVersions = [],
+ Collections = [],
+ CollectionItems = [],
+ TraktListItems = []
+ })
+ .Cast()
+ .ToList()
+ };
+}
diff --git a/ErsatzTV.Tests/Application/ProgramSchedules/GetProgramScheduleItemsWithDurationsHandlerTests.cs b/ErsatzTV.Tests/Application/ProgramSchedules/GetProgramScheduleItemsWithDurationsHandlerTests.cs
new file mode 100644
index 000000000..c1c1d525e
--- /dev/null
+++ b/ErsatzTV.Tests/Application/ProgramSchedules/GetProgramScheduleItemsWithDurationsHandlerTests.cs
@@ -0,0 +1,247 @@
+using ErsatzTV.Application.MediaCollections;
+using ErsatzTV.Application.ProgramSchedules;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Interfaces.Repositories;
+using ErsatzTV.Core.Scheduling;
+using MediatR;
+using NSubstitute;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Application.ProgramSchedules;
+
+[TestFixture]
+public class GetProgramScheduleItemsWithDurationsHandlerTests
+{
+ private IMediaCollectionRepository _mediaCollectionRepository = null!;
+ private IMediator _mediator = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _mediator = Substitute.For();
+ _mediaCollectionRepository = Substitute.For();
+ }
+
+ [Test]
+ public async Task Handle_Should_Estimate_Items_And_Total_From_Collection_Durations()
+ {
+ ProgramScheduleItemViewModel one = MakeOneItem(1, 10);
+ ProgramScheduleItemViewModel multipleCount = MakeMultipleItem(2, 10, MultipleMode.Count, "3");
+ ProgramScheduleItemViewModel multipleCollectionSize = MakeMultipleItem(3, 20, MultipleMode.CollectionSize, "0");
+ ProgramScheduleItemViewModel duration = MakeDurationItem(4, TimeSpan.FromMinutes(45));
+ ProgramScheduleItemViewModel flood = MakeFloodItem(5, 10);
+ ProgramScheduleItemViewModel remote = MakeOneItem(6, 30);
+
+ _mediator.Send(Arg.Is(q => q.Id == 99), Arg.Any())
+ .Returns([one, multipleCount, multipleCollectionSize, duration, flood, remote]);
+ _mediaCollectionRepository.GetItems(10)
+ .Returns([MakeMovie(30), MakeMovie(60), MakeMovie(0)]);
+ _mediaCollectionRepository.GetItems(20)
+ .Returns([MakeMovie(10), MakeMovie(20)]);
+ _mediaCollectionRepository.GetItems(30)
+ .Returns([MakeRemoteStreamWithFallbackDuration(20)]);
+
+ var handler = new GetProgramScheduleItemsWithDurationsHandler(_mediator, _mediaCollectionRepository);
+
+ ProgramScheduleItemsWithDurationViewModel result =
+ await handler.Handle(new GetProgramScheduleItemsWithDurations(99), CancellationToken.None);
+
+ result.Items[0].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(45));
+ result.Items[1].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(135));
+ result.Items[2].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(30));
+ result.Items[3].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(45));
+ result.Items[4].DurationEstimate.ShouldBeNull();
+ result.Items[5].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(20));
+ result.TotalDurationEstimate.ShouldBe(TimeSpan.FromMinutes(275));
+ }
+
+ [Test]
+ public async Task Handle_Should_Return_Null_Total_When_No_Items_Can_Be_Estimated()
+ {
+ ProgramScheduleItemViewModel one = MakeOneItem(1, 10);
+ ProgramScheduleItemViewModel flood = MakeFloodItem(2, 10);
+
+ _mediator.Send(Arg.Is(q => q.Id == 99), Arg.Any())
+ .Returns([one, flood]);
+ _mediaCollectionRepository.GetItems(10)
+ .Returns([MakeMovie(0)]);
+
+ var handler = new GetProgramScheduleItemsWithDurationsHandler(_mediator, _mediaCollectionRepository);
+
+ ProgramScheduleItemsWithDurationViewModel result =
+ await handler.Handle(new GetProgramScheduleItemsWithDurations(99), CancellationToken.None);
+
+ result.Items.ShouldAllBe(item => item.DurationEstimate == null);
+ result.TotalDurationEstimate.ShouldBeNull();
+ }
+
+ private static ProgramScheduleItemOneViewModel MakeOneItem(int id, int collectionId) =>
+ new(
+ id,
+ id,
+ StartType.Dynamic,
+ null,
+ null,
+ CollectionType.Collection,
+ MakeCollection(collectionId),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ PlaybackOrder.Shuffle,
+ MarathonGroupBy.None,
+ false,
+ false,
+ null,
+ FillWithGroupMode.None,
+ null,
+ GuideMode.Normal,
+ null,
+ null,
+ null,
+ null,
+ null,
+ [],
+ [],
+ null,
+ null,
+ null,
+ null);
+
+ private static ProgramScheduleItemMultipleViewModel MakeMultipleItem(
+ int id,
+ int collectionId,
+ MultipleMode multipleMode,
+ string count) =>
+ new(
+ id,
+ id,
+ StartType.Dynamic,
+ null,
+ null,
+ CollectionType.Collection,
+ MakeCollection(collectionId),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ PlaybackOrder.Shuffle,
+ MarathonGroupBy.None,
+ false,
+ false,
+ null,
+ FillWithGroupMode.None,
+ multipleMode,
+ count,
+ null,
+ GuideMode.Normal,
+ null,
+ null,
+ null,
+ null,
+ null,
+ [],
+ [],
+ null,
+ null,
+ null,
+ null);
+
+ private static ProgramScheduleItemDurationViewModel MakeDurationItem(int id, TimeSpan playoutDuration) =>
+ new(
+ id,
+ id,
+ StartType.Dynamic,
+ null,
+ null,
+ CollectionType.Collection,
+ MakeCollection(10),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ PlaybackOrder.Shuffle,
+ MarathonGroupBy.None,
+ false,
+ false,
+ null,
+ FillWithGroupMode.None,
+ playoutDuration,
+ TailMode.None,
+ 0,
+ null,
+ GuideMode.Normal,
+ null,
+ null,
+ null,
+ null,
+ null,
+ [],
+ [],
+ null,
+ null,
+ null,
+ null);
+
+ private static ProgramScheduleItemFloodViewModel MakeFloodItem(int id, int collectionId) =>
+ new(
+ id,
+ id,
+ StartType.Dynamic,
+ null,
+ null,
+ CollectionType.Collection,
+ MakeCollection(collectionId),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ PlaybackOrder.Shuffle,
+ MarathonGroupBy.None,
+ false,
+ false,
+ null,
+ FillWithGroupMode.None,
+ null,
+ GuideMode.Normal,
+ null,
+ null,
+ null,
+ null,
+ null,
+ [],
+ [],
+ null,
+ null,
+ null,
+ null);
+
+ private static MediaCollectionViewModel MakeCollection(int id) =>
+ new(CollectionType.Collection, id, $"Collection {id}", false, MediaItemState.Normal);
+
+ private static Movie MakeMovie(int minutes) =>
+ new()
+ {
+ MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(minutes) }]
+ };
+
+ private static RemoteStream MakeRemoteStreamWithFallbackDuration(int minutes) =>
+ new()
+ {
+ Duration = TimeSpan.FromMinutes(minutes),
+ MediaVersions = [new MediaVersion { Duration = TimeSpan.Zero }]
+ };
+}
diff --git a/ErsatzTV.Tests/Application/Watermarks/WatermarkHandlerTests.cs b/ErsatzTV.Tests/Application/Watermarks/WatermarkHandlerTests.cs
new file mode 100644
index 000000000..173ce4798
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Watermarks/WatermarkHandlerTests.cs
@@ -0,0 +1,71 @@
+using ErsatzTV.Application.Watermarks;
+using ErsatzTV.Core.Api.Watermarks;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.FFmpeg.State;
+using ErsatzTV.Infrastructure.Data;
+using ErsatzTV.Tests.Support;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Application.Watermarks;
+
+[TestFixture]
+public class WatermarkHandlerTests
+{
+ private InMemoryTvContext _db = null!;
+
+ [SetUp]
+ public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
+
+ [TearDown]
+ public async Task TearDown() => await _db.DisposeAsync();
+
+ [Test]
+ public async Task GetAllWatermarksForApi_Should_Return_All_Watermarks()
+ {
+ await SeedWatermark(1, "Bug");
+ await SeedWatermark(2, "Logo");
+
+ var handler = new GetAllWatermarksForApiHandler(_db.Factory);
+
+ List result =
+ await handler.Handle(new GetAllWatermarksForApi(), CancellationToken.None);
+
+ result.Count.ShouldBe(2);
+ result.ShouldContain(new WatermarkResponseModel(1, "Bug"));
+ result.ShouldContain(new WatermarkResponseModel(2, "Logo"));
+ }
+
+ [Test]
+ public async Task GetAllWatermarksForApi_Should_Return_Empty_List_When_None_Exist()
+ {
+ var handler = new GetAllWatermarksForApiHandler(_db.Factory);
+
+ List result =
+ await handler.Handle(new GetAllWatermarksForApi(), CancellationToken.None);
+
+ result.ShouldBeEmpty();
+ }
+
+ private async Task SeedWatermark(int id, string name)
+ {
+ await using TvContext context = _db.CreateContext();
+ context.ChannelWatermarks.Add(new ChannelWatermark
+ {
+ Id = id,
+ Name = name,
+ Mode = ChannelWatermarkMode.Permanent,
+ ImageSource = ChannelWatermarkImageSource.Custom,
+ Image = "watermark.png",
+ Location = WatermarkLocation.BottomRight,
+ Size = WatermarkSize.Scaled,
+ WidthPercent = 10,
+ HorizontalMarginPercent = 2,
+ VerticalMarginPercent = 2,
+ FrequencyMinutes = 15,
+ DurationSeconds = 30,
+ Opacity = 100
+ });
+ await context.SaveChangesAsync();
+ }
+}
diff --git a/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs b/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs
new file mode 100644
index 000000000..8d2f44457
--- /dev/null
+++ b/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs
@@ -0,0 +1,164 @@
+using System.Reflection;
+using ErsatzTV.Application.Artworks;
+using ErsatzTV.Controllers.Api;
+using ErsatzTV.Core;
+using ErsatzTV.Core.Api.Artwork;
+using ErsatzTV.Core.Domain;
+using MediatR;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Routing;
+using NSubstitute;
+using NUnit.Framework;
+using Shouldly;
+using static LanguageExt.Prelude;
+
+namespace ErsatzTV.Tests.Controllers;
+
+[TestFixture]
+public class ArtworkUploadControllerTests
+{
+ private ArtworkUploadController _controller = null!;
+ private IMediator _mediator = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _mediator = Substitute.For();
+ _controller = new ArtworkUploadController(_mediator);
+ }
+
+ [Test]
+ public void Controller_Should_Expose_Idiomatic_Rest_Route()
+ {
+ MethodInfo action = typeof(ArtworkUploadController).GetMethod(nameof(ArtworkUploadController.Upload))
+ ?? throw new AssertionException("Missing action Upload");
+
+ HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single();
+ attribute.HttpMethods.ShouldContain("POST");
+ attribute.Template.ShouldBe("/api/artwork/uploads");
+ attribute.Name.ShouldBe("UploadArtwork");
+ }
+
+ [Test]
+ public void Action_Should_Consume_Multipart_Form_Data()
+ {
+ MethodInfo action = typeof(ArtworkUploadController).GetMethod(nameof(ArtworkUploadController.Upload))
+ ?? throw new AssertionException("Missing action Upload");
+
+ var consumes = action.GetCustomAttribute();
+ consumes.ShouldNotBeNull();
+ consumes.ContentTypes.ShouldContain("multipart/form-data");
+ }
+
+ [Test]
+ public async Task Upload_Should_Return_422_When_File_Missing()
+ {
+ IActionResult result = await _controller.Upload(null!, "logo", CancellationToken.None);
+
+ var unprocessable = result.ShouldBeOfType();
+ var problem = unprocessable.Value.ShouldBeOfType();
+ problem.Status.ShouldBe(422);
+ problem.Title.ShouldBe("Validation failed");
+ await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any());
+ }
+
+ [Test]
+ public async Task Upload_Should_Return_422_When_File_Empty()
+ {
+ IFormFile emptyFile = MakeFormFile([], "image/png");
+
+ IActionResult result = await _controller.Upload(emptyFile, "logo", CancellationToken.None);
+
+ result.ShouldBeOfType();
+ await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any());
+ }
+
+ [Test]
+ public async Task Upload_Should_Return_422_When_File_Exceeds_Maximum_Size()
+ {
+ var oversizeBytes = new byte[(SystemEnvironment.MaximumUploadMb * 1024 * 1024) + 1];
+ IFormFile oversizeFile = MakeFormFile(oversizeBytes, "image/png");
+
+ IActionResult result = await _controller.Upload(oversizeFile, "logo", CancellationToken.None);
+
+ var unprocessable = result.ShouldBeOfType();
+ var problem = unprocessable.Value.ShouldBeOfType();
+ problem.Detail.ShouldContain("maximum allowed size");
+ await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any());
+ }
+
+ [Test]
+ public async Task Upload_Should_Return_422_For_Unknown_Target()
+ {
+ IFormFile file = MakeFormFile([1, 2, 3], "image/png");
+
+ IActionResult result = await _controller.Upload(file, "poster", CancellationToken.None);
+
+ var unprocessable = result.ShouldBeOfType();
+ var problem = unprocessable.Value.ShouldBeOfType();
+ problem.Detail.ShouldContain("Unknown upload target");
+ await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any());
+ }
+
+ [Test]
+ public async Task Upload_Should_Send_UploadArtwork_With_Logo_Kind_And_Return_201()
+ {
+ IFormFile file = MakeFormFile([1, 2, 3], "image/png");
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Right(
+ new ArtworkUploadResponseModel("iptv/logos/abc.png", "image/png")));
+
+ IActionResult result = await _controller.Upload(file, "logo", CancellationToken.None);
+
+ var created = result.ShouldBeOfType();
+ created.StatusCode.ShouldBe(201);
+ created.Location.ShouldBe("/iptv/logos/abc.png?contentType=image%2Fpng");
+ created.Value.ShouldBeOfType()
+ .Path.ShouldBe("iptv/logos/abc.png");
+
+ await _mediator.Received(1).Send(
+ Arg.Is(c => c.ArtworkKind == ArtworkKind.Logo && c.ContentType == "image/png"),
+ Arg.Any());
+ }
+
+ [Test]
+ public async Task Upload_Should_Send_UploadArtwork_With_Watermark_Kind_And_Return_201()
+ {
+ IFormFile file = MakeFormFile([1, 2, 3], "image/webp");
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Right(
+ new ArtworkUploadResponseModel("def.webp", "image/webp")));
+
+ IActionResult result = await _controller.Upload(file, "watermark", CancellationToken.None);
+
+ var created = result.ShouldBeOfType();
+ created.Location.ShouldBe("/artwork/watermarks/def.webp?contentType=image%2Fwebp");
+
+ await _mediator.Received(1).Send(
+ Arg.Is(c => c.ArtworkKind == ArtworkKind.Watermark),
+ Arg.Any());
+ }
+
+ [Test]
+ public async Task Upload_Should_Return_422_On_Handler_Validation_Error()
+ {
+ IFormFile file = MakeFormFile([1, 2, 3], "image/bmp");
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Left(BaseError.New("unsupported content type")));
+
+ IActionResult result = await _controller.Upload(file, "logo", CancellationToken.None);
+
+ var unprocessable = result.ShouldBeOfType();
+ var problem = unprocessable.Value.ShouldBeOfType();
+ problem.Status.ShouldBe(422);
+ problem.Title.ShouldBe("Validation failed");
+ }
+
+ private static IFormFile MakeFormFile(byte[] bytes, string contentType) =>
+ new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "upload.bin")
+ {
+ Headers = new HeaderDictionary(),
+ ContentType = contentType
+ };
+}
diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs
index dde508850..5593c9164 100644
--- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs
+++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs
@@ -9,6 +9,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
+using ErsatzTV.Core.Scheduling;
using LanguageExt;
using static LanguageExt.Prelude;
using MediatR;
@@ -24,15 +25,15 @@ namespace ErsatzTV.Tests.Controllers;
public class ChannelControllerTests
{
private IMediator _mediator = null!;
+ private Channel _workerChannel = null!;
private ChannelController _controller = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For();
- ChannelWriter writer =
- System.Threading.Channels.Channel.CreateUnbounded().Writer;
- _controller = new ChannelController(writer, _mediator);
+ _workerChannel = System.Threading.Channels.Channel.CreateUnbounded();
+ _controller = new ChannelController(_workerChannel.Writer, _mediator);
}
[Test]
@@ -162,6 +163,22 @@ public class ChannelControllerTests
Arg.Any());
}
+ [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(), Arg.Any()).Returns(model);
+
+ ChannelGuideResponseModel result = await _controller.GetGuide(start, end, CancellationToken.None);
+
+ result.ShouldBe(model);
+ await _mediator.Received(1).Send(
+ Arg.Is(q => q.Start == start && q.End == end),
+ Arg.Any());
+ }
+
[Test]
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
{
@@ -192,6 +209,19 @@ public class ChannelControllerTests
Arg.Any());
}
+ [Test]
+ public async Task GetGuide_Should_Pass_Null_Bounds_Through()
+ {
+ var model = new ChannelGuideResponseModel(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, []);
+ _mediator.Send(Arg.Any(), Arg.Any()).Returns(model);
+
+ await _controller.GetGuide(null, null, CancellationToken.None);
+
+ await _mediator.Received(1).Send(
+ Arg.Is(q => q.Start == null && q.End == null),
+ Arg.Any());
+ }
+
[Test]
public async Task BulkMoveToGroup_Should_Return_404_For_NotFoundError()
{
@@ -266,7 +296,7 @@ public class ChannelControllerTests
_mediator.Send(Arg.Any(), Arg.Any())
.Returns(Option.None);
- IActionResult result = await _controller.ResetPlayout("404");
+ IActionResult result = await _controller.ResetPlayout("404", mode: null, CancellationToken.None);
var notFound = result.ShouldBeOfType();
var problemDetails = notFound.Value.ShouldBeOfType();
@@ -274,6 +304,53 @@ public class ChannelControllerTests
problemDetails.Title.ShouldBe("Resource not found");
}
+ [TestCase(PlayoutScheduleKind.Classic, PlayoutBuildMode.Refresh)]
+ [TestCase(PlayoutScheduleKind.Block, PlayoutBuildMode.Reset)]
+ [TestCase(PlayoutScheduleKind.Sequential, PlayoutBuildMode.Reset)]
+ public async Task ResetPlayout_Should_Default_Mode_By_ScheduleKind(
+ PlayoutScheduleKind scheduleKind,
+ PlayoutBuildMode expectedMode)
+ {
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Option.Some(9));
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Option.Some(MakePlayout(9, scheduleKind)));
+
+ IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
+
+ result.ShouldBeOfType();
+ _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
+ var buildPlayout = request.ShouldBeOfType();
+ buildPlayout.PlayoutId.ShouldBe(9);
+ buildPlayout.Mode.ShouldBe(expectedMode);
+ }
+
+ [Test]
+ public async Task ResetPlayout_Should_Honor_Explicit_Mode()
+ {
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Option.Some(9));
+
+ IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None);
+
+ result.ShouldBeOfType();
+ _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
+ request.ShouldBeOfType().Mode.ShouldBe(PlayoutBuildMode.Continue);
+ await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any());
+ }
+
+ private static PlayoutNameViewModel MakePlayout(int id, PlayoutScheduleKind scheduleKind) =>
+ new(
+ id,
+ scheduleKind,
+ "Channel",
+ "5",
+ ChannelPlayoutMode.Continuous,
+ "Schedule",
+ string.Empty,
+ null,
+ null);
+
private static ChannelViewModel MakeVm(int id) =>
new(
id,
diff --git a/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs b/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs
new file mode 100644
index 000000000..5747efc03
--- /dev/null
+++ b/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs
@@ -0,0 +1,63 @@
+using System.Reflection;
+using ErsatzTV.Application.Filler;
+using ErsatzTV.Controllers.Api;
+using ErsatzTV.Core.Api.Filler;
+using MediatR;
+using Microsoft.AspNetCore.Mvc.Routing;
+using NSubstitute;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Controllers;
+
+[TestFixture]
+public class FillerPresetControllerTests
+{
+ private FillerPresetController _controller = null!;
+ private IMediator _mediator = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _mediator = Substitute.For();
+ _controller = new FillerPresetController(_mediator);
+ }
+
+ [Test]
+ public void Controller_Should_Expose_Idiomatic_Rest_Route()
+ {
+ MethodInfo action = typeof(FillerPresetController).GetMethod(nameof(FillerPresetController.GetAll))
+ ?? throw new AssertionException("Missing action GetAll");
+
+ HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single();
+ attribute.HttpMethods.ShouldContain("GET");
+ attribute.Template.ShouldBe("/api/filler-presets");
+ }
+
+ [Test]
+ public async Task GetAll_Should_Return_FillerPresets()
+ {
+ List models =
+ [
+ new FillerPresetResponseModel(1, "Intro"),
+ new FillerPresetResponseModel(2, "Outro")
+ ];
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(models);
+
+ List result = await _controller.GetAll(CancellationToken.None);
+
+ result.ShouldBe(models);
+ }
+
+ [Test]
+ public async Task GetAll_Should_Return_Empty_List_When_None_Exist()
+ {
+ _mediator.Send(Arg.Any(), Arg.Any