Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6197d63ff | ||
|
|
6a9f1e41a4 | ||
|
|
a6056f73d8 | ||
|
|
da7636fdad | ||
|
|
cc6ffcb8c4 | ||
|
|
f9bb230673 | ||
|
|
6350845101 | ||
|
|
9cd107bc8d | ||
|
|
3e8cfa6288 | ||
|
|
692a71fd13 | ||
|
|
2952aceb5b | ||
|
|
1b6de047a0 | ||
|
|
8d89ab1624 | ||
|
|
7bd694394a | ||
|
|
06355b7590 | ||
|
|
6f6f37b7f6 | ||
|
|
0585f4a7f8 | ||
|
|
ef024c0a05 | ||
|
|
12aa57ccc9 | ||
|
|
9ecefd75f3 | ||
|
|
43f81d3a49 | ||
|
|
14d06a1e49 | ||
|
|
4b4e2b2f82 | ||
|
|
d0652d4adb | ||
|
|
daf94f73f8 | ||
|
|
3d086aabc1 | ||
|
|
b9955f4cba | ||
|
|
69486ab2d6 | ||
|
|
1b7b0e549a | ||
|
|
abd8ca34c9 | ||
|
|
20ca71b388 | ||
|
|
335a8b8a77 | ||
|
|
922bec4b24 | ||
|
|
272174ee75 | ||
|
|
29407f637b | ||
|
|
83c9122b6f | ||
|
|
86f07594e4 | ||
|
|
446f50763a | ||
|
|
0eaedb9cf6 | ||
|
|
8912686a47 | ||
|
|
d1dfe6eb5a |
@@ -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.
|
||||
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Artwork;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>IImageCache</c>),
|
||||
/// so the returned path is equivalent to a Blazor-uploaded image.
|
||||
/// </summary>
|
||||
public record UploadArtwork(Stream Stream, string ContentType, ArtworkKind ArtworkKind)
|
||||
: IRequest<Either<BaseError, ArtworkUploadResponseModel>>;
|
||||
@@ -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<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
|
||||
{
|
||||
// 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<string> 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<Either<BaseError, ArtworkUploadResponseModel>> 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<BaseError, string> 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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Shared programme-metadata projection for guide output. Both the XMLTV cache builder
|
||||
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
|
||||
/// (<see cref="GetChannelGuideDataHandler" />) resolve the display title/subtitle/category from a
|
||||
/// <see cref="PlayoutItem" /> here so the two representations stay consistent.
|
||||
/// </summary>
|
||||
public static class ChannelGuideMetadata
|
||||
{
|
||||
public static string GetTitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return playoutItem.CustomTitle;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
|
||||
.IfNone("[unknown movie]"),
|
||||
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||
.IfNone("[unknown show]"),
|
||||
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown remote stream]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetSubtitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
Song s => s.SongMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The primary guide category, mirroring the fixed <c><category></c> the XMLTV templates
|
||||
/// emit per media kind (Movie / Series / Music). Media kinds without a fixed category return null.
|
||||
/// </summary>
|
||||
public static string GetCategory(PlayoutItem playoutItem) =>
|
||||
playoutItem.MediaItem switch
|
||||
{
|
||||
Movie => "Movie",
|
||||
Episode => "Series",
|
||||
MusicVideo => "Music",
|
||||
Song => "Music",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// A single guide programme resolved from one or more <see cref="PlayoutItem" />s: the
|
||||
/// <see cref="DisplayItem" /> whose metadata is shown, plus the coalesced <see cref="Start" />/
|
||||
/// <see cref="Stop" /> window and whether the originating item carried a custom title.
|
||||
/// </summary>
|
||||
public readonly record struct ChannelGuideEntry(
|
||||
PlayoutItem DisplayItem,
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Stop,
|
||||
bool HasCustomTitle);
|
||||
|
||||
/// <summary>
|
||||
/// Shared guide-group / filler-merge projection. This is the single source of truth for turning a
|
||||
/// channel's sorted <see cref="PlayoutItem" />s into guide programmes; both the XMLTV cache builder
|
||||
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
|
||||
/// (<see cref="GetChannelGuideDataHandler" />) consume it so the two representations cannot drift.
|
||||
/// The XMLTV path formats <see cref="ChannelGuideEntry.Start" />/<see cref="ChannelGuideEntry.Stop" />
|
||||
/// into the XMLTV timestamp strings; the JSON path returns them (and the display item's
|
||||
/// <see cref="FillerKind" />) directly and lets the UI decide how to render filler.
|
||||
/// </summary>
|
||||
public static class ChannelGuideProjector
|
||||
{
|
||||
public static IEnumerable<ChannelGuideEntry> Project(
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
IReadOnlyList<PlayoutItem> sorted,
|
||||
XmltvTimeZone timeZone,
|
||||
XmltvBlockBehavior blockBehavior) =>
|
||||
scheduleKind switch
|
||||
{
|
||||
PlayoutScheduleKind.Block => ProjectBlock(sorted, timeZone, blockBehavior),
|
||||
_ => ProjectFlood(sorted, timeZone)
|
||||
};
|
||||
|
||||
// Classic / Sequential / Scripted / ExternalJson: skip leading non-preroll filler, then coalesce
|
||||
// each guide group (following filler) into a single programme using the display item's GuideFinish
|
||||
// override when present.
|
||||
private static IEnumerable<ChannelGuideEntry> ProjectFlood(
|
||||
IReadOnlyList<PlayoutItem> sorted,
|
||||
XmltvTimeZone timeZone)
|
||||
{
|
||||
// skip all filler that isn't pre-roll
|
||||
var i = 0;
|
||||
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
|
||||
sorted[i].FillerKind != FillerKind.PreRoll)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < sorted.Count)
|
||||
{
|
||||
PlayoutItem startItem = sorted[i];
|
||||
int j = i;
|
||||
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
PlayoutItem displayItem = sorted[j];
|
||||
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
|
||||
|
||||
int finishIndex = j;
|
||||
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
||||
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
||||
or FillerKind.PostRoll or FillerKind.Tail
|
||||
or FillerKind.Fallback or FillerKind.DecoDefault))
|
||||
{
|
||||
finishIndex++;
|
||||
}
|
||||
|
||||
PlayoutItem finishItem = sorted[finishIndex];
|
||||
i = finishIndex;
|
||||
|
||||
DateTimeOffset startTime = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
|
||||
_ => startItem.StartOffset
|
||||
};
|
||||
|
||||
DateTimeOffset stopTime = (timeZone, displayItem.GuideFinishOffset.HasValue) switch
|
||||
{
|
||||
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
|
||||
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
|
||||
(_, true) => displayItem.GuideFinishOffset!.Value,
|
||||
(_, false) => finishItem.FinishOffset
|
||||
};
|
||||
|
||||
yield return new ChannelGuideEntry(displayItem, startTime, stopTime, hasCustomTitle);
|
||||
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Block: group by guide window, drop filler entirely, then either use the items' actual times or
|
||||
// split the group window evenly across the non-filler items.
|
||||
private static IEnumerable<ChannelGuideEntry> ProjectBlock(
|
||||
IReadOnlyList<PlayoutItem> sorted,
|
||||
XmltvTimeZone timeZone,
|
||||
XmltvBlockBehavior blockBehavior)
|
||||
{
|
||||
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
|
||||
if (itemsToInclude.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (blockBehavior)
|
||||
{
|
||||
case XmltvBlockBehavior.UseActualTimes:
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
DateTimeOffset actualStart = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset actualFinish = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
yield return new ChannelGuideEntry(item, actualStart, actualFinish, false);
|
||||
}
|
||||
|
||||
break;
|
||||
case XmltvBlockBehavior.SplitTimeEvenly:
|
||||
default:
|
||||
DateTime groupStart = group.Key.GuideStart!.Value;
|
||||
DateTime groupFinish = group.Key.GuideFinish!.Value;
|
||||
TimeSpan groupDuration = groupFinish - groupStart;
|
||||
|
||||
TimeSpan perItem = groupDuration / itemsToInclude.Count;
|
||||
|
||||
DateTimeOffset currentStart = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset currentFinish = currentStart + perItem;
|
||||
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
yield return new ChannelGuideEntry(item, currentStart, currentFinish, false);
|
||||
|
||||
currentStart = currentFinish;
|
||||
currentFinish += perItem;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,89 +129,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Studios)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Directors)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artists)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.IncludeGuideMetadata()
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -244,8 +162,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WritePlayoutXml(
|
||||
await WriteScheduleXml(
|
||||
request,
|
||||
playout.ScheduleKind,
|
||||
floodSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
@@ -270,8 +189,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WriteBlockPlayoutXml(
|
||||
await WriteScheduleXml(
|
||||
request,
|
||||
playout.ScheduleKind,
|
||||
blockSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
@@ -294,8 +214,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WritePlayoutXml(
|
||||
await WriteScheduleXml(
|
||||
request,
|
||||
playout.ScheduleKind,
|
||||
externalJsonSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
@@ -324,100 +245,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WritePlayoutXml(
|
||||
RefreshChannelData request,
|
||||
List<PlayoutItem> sorted,
|
||||
XmlTemplateContext templateContext,
|
||||
Template movieTemplate,
|
||||
Template episodeTemplate,
|
||||
Template musicVideoTemplate,
|
||||
Template songTemplate,
|
||||
Template otherVideoTemplate,
|
||||
Template remoteStreamTemplate,
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
XmltvTimeZone xmltvTimeZone = await _configElementRepository
|
||||
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
|
||||
.IfNoneAsync(XmltvTimeZone.Local);
|
||||
|
||||
// skip all filler that isn't pre-roll
|
||||
var i = 0;
|
||||
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
|
||||
sorted[i].FillerKind != FillerKind.PreRoll)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < sorted.Count)
|
||||
{
|
||||
PlayoutItem startItem = sorted[i];
|
||||
int j = i;
|
||||
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
PlayoutItem displayItem = sorted[j];
|
||||
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
|
||||
|
||||
int finishIndex = j;
|
||||
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
||||
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
||||
or FillerKind.PostRoll or FillerKind.Tail
|
||||
or FillerKind.Fallback or FillerKind.DecoDefault))
|
||||
{
|
||||
finishIndex++;
|
||||
}
|
||||
|
||||
PlayoutItem finishItem = sorted[finishIndex];
|
||||
i = finishIndex;
|
||||
|
||||
DateTimeOffset startTime = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
|
||||
_ => startItem.StartOffset
|
||||
};
|
||||
|
||||
DateTimeOffset stopTime = (xmltvTimeZone, displayItem.GuideFinishOffset.HasValue) switch
|
||||
{
|
||||
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
|
||||
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
|
||||
(_, true) => displayItem.GuideFinishOffset!.Value,
|
||||
(_, false) => finishItem.FinishOffset
|
||||
};
|
||||
|
||||
string start = startTime
|
||||
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
string stop = stopTime
|
||||
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
displayItem,
|
||||
start,
|
||||
stop,
|
||||
hasCustomTitle,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteBlockPlayoutXml(
|
||||
private async Task WriteScheduleXml(
|
||||
RefreshChannelData request,
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
List<PlayoutItem> sorted,
|
||||
XmlTemplateContext templateContext,
|
||||
Template movieTemplate,
|
||||
@@ -438,98 +268,36 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
|
||||
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
|
||||
|
||||
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
|
||||
foreach (var group in groups)
|
||||
// guide-group / filler-merge logic is shared with the JSON guide query so the two cannot drift
|
||||
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
|
||||
scheduleKind,
|
||||
sorted,
|
||||
xmltvTimeZone,
|
||||
xmltvBlockBehavior))
|
||||
{
|
||||
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
|
||||
if (itemsToInclude.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string start = entry.Start
|
||||
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
switch (xmltvBlockBehavior)
|
||||
{
|
||||
case XmltvBlockBehavior.UseActualTimes:
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
DateTimeOffset actualStart = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
string stop = entry.Stop
|
||||
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
DateTimeOffset actualFinish = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
string start = actualStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
string stop = actualFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
item,
|
||||
start,
|
||||
stop,
|
||||
false,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
}
|
||||
break;
|
||||
case XmltvBlockBehavior.SplitTimeEvenly:
|
||||
default:
|
||||
DateTime groupStart = group.Key.GuideStart!.Value;
|
||||
DateTime groupFinish = group.Key.GuideFinish!.Value;
|
||||
TimeSpan groupDuration = groupFinish - groupStart;
|
||||
|
||||
TimeSpan perItem = groupDuration / itemsToInclude.Count;
|
||||
|
||||
DateTimeOffset currentStart = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset currentFinish = currentStart + perItem;
|
||||
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
item,
|
||||
start,
|
||||
stop,
|
||||
false,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
|
||||
currentStart = currentFinish;
|
||||
currentFinish += perItem;
|
||||
}
|
||||
break;
|
||||
}
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
entry.DisplayItem,
|
||||
start,
|
||||
stop,
|
||||
entry.HasCustomTitle,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,8 +317,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml)
|
||||
{
|
||||
string title = GetTitle(displayItem);
|
||||
string subtitle = GetSubtitle(displayItem);
|
||||
string title = ChannelGuideMetadata.GetTitle(displayItem);
|
||||
string subtitle = ChannelGuideMetadata.GetSubtitle(displayItem);
|
||||
|
||||
Option<string> maybeTemplateOutput = displayItem.MediaItem switch
|
||||
{
|
||||
@@ -1117,51 +885,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
return artworkPath;
|
||||
}
|
||||
|
||||
private static string GetTitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return playoutItem.CustomTitle;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
|
||||
.IfNone("[unknown movie]"),
|
||||
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||
.IfNone("[unknown show]"),
|
||||
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown remote stream]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSubtitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
Song s => s.SongMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetPrioritizedArtworkPath(Metadata metadata)
|
||||
{
|
||||
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// JSON channel-guide query for the EPG grid. <paramref name="Start" /> defaults to now and
|
||||
/// <paramref name="End" /> defaults to now + the configured XmltvDaysToBuild window.
|
||||
/// </summary>
|
||||
public record GetChannelGuideData(DateTimeOffset? Start, DateTimeOffset? End)
|
||||
: IRequest<ChannelGuideResponseModel>;
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the JSON channel guide directly from <see cref="Playout" /> items, using the shared
|
||||
/// <see cref="ChannelGuideProjector" /> guide-group/filler-merge logic (the same logic the XMLTV
|
||||
/// cache builder uses) so the two representations cannot drift. Only channels with
|
||||
/// <see cref="Channel.ShowInEpg" /> are included, mirroring <c>GetChannelGuideHandler</c>.
|
||||
/// Unlike XMLTV, filler programmes are returned (with their <see cref="Core.Domain.Filler.FillerKind" />)
|
||||
/// so the UI can decide how to render them.
|
||||
/// </summary>
|
||||
public class GetChannelGuideDataHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetChannelGuideData, ChannelGuideResponseModel>
|
||||
{
|
||||
public async Task<ChannelGuideResponseModel> Handle(
|
||||
GetChannelGuideData request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
int daysToBuild = await configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
XmltvTimeZone xmltvTimeZone = await configElementRepository
|
||||
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
|
||||
.IfNoneAsync(XmltvTimeZone.Local);
|
||||
|
||||
XmltvBlockBehavior xmltvBlockBehavior = await configElementRepository
|
||||
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
|
||||
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
|
||||
|
||||
DateTimeOffset start = request.Start ?? DateTimeOffset.UtcNow;
|
||||
DateTimeOffset end = request.End ?? start.AddDays(daysToBuild);
|
||||
|
||||
// Visible channels only (mirror GetChannelGuideHandler's ShowInEpg == false skip).
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Where(c => c.ShowInEpg)
|
||||
.Include(c => c.MirrorSourceChannel)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Order channels by their decimal channel number so "2" precedes "10", matching
|
||||
// ChannelGuide.ToXml (which orders XMLTV channels by decimal.Parse of the number).
|
||||
channels = channels
|
||||
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture))
|
||||
.ToList();
|
||||
|
||||
var responseChannels = new List<ChannelGuideChannelResponseModel>();
|
||||
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
bool isMirror = channel.PlayoutSource == ChannelPlayoutSource.Mirror
|
||||
&& channel.MirrorSourceChannel is not null;
|
||||
|
||||
string sourceChannelNumber = isMirror ? channel.MirrorSourceChannel.Number : channel.Number;
|
||||
TimeSpan playoutOffset = isMirror ? channel.PlayoutOffset ?? TimeSpan.Zero : TimeSpan.Zero;
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(p => p.Channel.Number == sourceChannelNumber)
|
||||
.IncludeGuideMetadata()
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var programmes = new List<ChannelGuideProgrammeResponseModel>();
|
||||
|
||||
foreach (Playout playout in playouts)
|
||||
{
|
||||
// ExternalJson playouts materialize items from a file rather than Playout.Items; they are
|
||||
// out of scope for the JSON guide (see issue #102 notes).
|
||||
if (playout.ScheduleKind is PlayoutScheduleKind.ExternalJson)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter to the window (on the pre-offset time, mirroring the XMLTV builder) then apply the
|
||||
// mirror playout offset without mutating the loaded (shared, AsNoTracking) entities.
|
||||
List<PlayoutItem> sorted = playout.Items
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Filter(pi => pi.StartOffset <= end)
|
||||
.Select(pi => playoutOffset == TimeSpan.Zero ? pi : WithPlayoutOffset(pi, playoutOffset))
|
||||
.ToList();
|
||||
|
||||
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
|
||||
playout.ScheduleKind,
|
||||
sorted,
|
||||
xmltvTimeZone,
|
||||
xmltvBlockBehavior))
|
||||
{
|
||||
// drop programmes that finish before the requested window starts
|
||||
if (entry.Stop <= start)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string subtitle = ChannelGuideMetadata.GetSubtitle(entry.DisplayItem);
|
||||
|
||||
programmes.Add(
|
||||
new ChannelGuideProgrammeResponseModel(
|
||||
entry.Start,
|
||||
entry.Stop,
|
||||
ChannelGuideMetadata.GetTitle(entry.DisplayItem),
|
||||
string.IsNullOrWhiteSpace(subtitle) ? null : subtitle,
|
||||
ChannelGuideMetadata.GetCategory(entry.DisplayItem),
|
||||
entry.DisplayItem.FillerKind));
|
||||
}
|
||||
}
|
||||
|
||||
responseChannels.Add(
|
||||
new ChannelGuideChannelResponseModel(
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
programmes.OrderBy(p => p.Start).ToList()));
|
||||
}
|
||||
|
||||
return new ChannelGuideResponseModel(start, end, responseChannels);
|
||||
}
|
||||
|
||||
// Copy (don't mutate) the loaded PlayoutItem when shifting by the mirror playout offset. The loaded
|
||||
// entities are AsNoTracking and shared; mutating them in place would corrupt the guide projection.
|
||||
// Mirrors the XMLTV builder, which shifts only Start/Finish (not the Guide* window).
|
||||
private static PlayoutItem WithPlayoutOffset(PlayoutItem item, TimeSpan offset) =>
|
||||
new()
|
||||
{
|
||||
MediaItem = item.MediaItem,
|
||||
Start = item.Start + offset,
|
||||
Finish = item.Finish + offset,
|
||||
GuideStart = item.GuideStart,
|
||||
GuideFinish = item.GuideFinish,
|
||||
GuideGroup = item.GuideGroup,
|
||||
FillerKind = item.FillerKind,
|
||||
CustomTitle = item.CustomTitle
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
|
||||
@@ -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<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllFillerPresetsForApi, List<FillerPresetResponseModel>>
|
||||
{
|
||||
public async Task<List<FillerPresetResponseModel>> Handle(
|
||||
GetAllFillerPresetsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<FillerPreset> fillerPresets = await dbContext.FillerPresets
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
return fillerPresets.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public record GetAllGraphicsElementsForApi : IRequest<List<GraphicsElementResponseModel>>;
|
||||
@@ -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<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllGraphicsElementsForApi, List<GraphicsElementResponseModel>>
|
||||
{
|
||||
public async Task<List<GraphicsElementResponseModel>> Handle(
|
||||
GetAllGraphicsElementsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<GraphicsElement> 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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
|
||||
namespace ErsatzTV.Application.Health;
|
||||
|
||||
public record GetAllHealthCheckResultsForApi : IRequest<List<HealthCheckResponseModel>>;
|
||||
@@ -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<GetAllHealthCheckResultsForApi, List<HealthCheckResponseModel>>
|
||||
{
|
||||
private readonly IHealthCheckService _healthCheckService;
|
||||
|
||||
public GetAllHealthCheckResultsForApiHandler(IHealthCheckService healthCheckService) =>
|
||||
_healthCheckService = healthCheckService;
|
||||
|
||||
public async Task<List<HealthCheckResponseModel>> Handle(
|
||||
GetAllHealthCheckResultsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
List<HealthCheckResult> 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record GetLibraryScanStatus : IRequest<List<LibraryScanStatusResponseModel>>;
|
||||
@@ -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<GetLibraryScanStatus, List<LibraryScanStatusResponseModel>>
|
||||
{
|
||||
public Task<List<LibraryScanStatusResponseModel>> Handle(
|
||||
GetLibraryScanStatus request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<LibraryScanStatusResponseModel> result = scannerProxyService.GetActiveScans()
|
||||
.Select(scan => new LibraryScanStatusResponseModel(scan.LibraryId, scan.Progress))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
|
||||
public record GetAllMediaSourcesForApi : IRequest<List<MediaSourceResponseModel>>;
|
||||
@@ -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<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
|
||||
{
|
||||
public async Task<List<MediaSourceResponseModel>> Handle(
|
||||
GetAllMediaSourcesForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<MediaSource> mediaSources = await dbContext.MediaSources
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Libraries)
|
||||
.ThenInclude(l => l.Paths)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
Dictionary<int, int> itemCountsByLibrary = await GetItemCountsByLibrary(dbContext, cancellationToken);
|
||||
Dictionary<int, string> addressByMediaSourceId = await GetConnectionAddresses(dbContext, cancellationToken);
|
||||
|
||||
var result = new List<MediaSourceResponseModel>();
|
||||
foreach (MediaSource mediaSource in mediaSources)
|
||||
{
|
||||
List<MediaSourceLibraryResponseModel> 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<Dictionary<int, int>> GetItemCountsByLibrary(
|
||||
TvContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<LibraryItemCount> counts = await dbContext.Connection.QueryAsync<LibraryItemCount>(
|
||||
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<Dictionary<int, string>> GetConnectionAddresses(
|
||||
TvContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var addresses = new Dictionary<int, string>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> 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,
|
||||
|
||||
@@ -44,6 +44,33 @@ public abstract record ProgramScheduleItemViewModel(
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode? SubtitleMode)
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// Semantics by <see cref="PlayoutMode" />:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>One</b> — the average runtime of one item in the referenced collection.</item>
|
||||
/// <item>
|
||||
/// <b>Multiple</b> — the average item runtime multiplied by the configured count
|
||||
/// (<see cref="MultipleMode.Count" />), or the whole collection runtime for
|
||||
/// <see cref="MultipleMode.CollectionSize" />. An expression-based (non-integer)
|
||||
/// count cannot be evaluated here and yields <c>null</c>.
|
||||
/// </item>
|
||||
/// <item><b>Flood</b> — always <c>null</c>: a flood item fills the remaining time and is unbounded.</item>
|
||||
/// <item><b>Duration</b> — the explicit <c>playoutDuration</c> setting on the item.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>null</c> 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 <see cref="CollectionType.Collection" />
|
||||
/// (smart/multi/playlist/search/rerun/show/season/artist references are not aggregated in this pass).
|
||||
/// Callers should treat a <c>null</c> as "unknown", never as zero.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public TimeSpan? DurationEstimate { get; init; }
|
||||
|
||||
public string Name => CollectionType switch
|
||||
{
|
||||
CollectionType.Collection => Collection?.Name,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
/// <summary>
|
||||
/// The items of a schedule together with computed runtime estimates. Each item carries its own
|
||||
/// <see cref="ProgramScheduleItemViewModel.DurationEstimate" /> (nullable — see that property for
|
||||
/// the per-mode semantics), and <see cref="TotalDurationEstimate" /> is the sum of the items that
|
||||
/// could be estimated.
|
||||
/// </summary>
|
||||
/// <param name="Items">The schedule items, each with a nullable <c>DurationEstimate</c>.</param>
|
||||
/// <param name="TotalDurationEstimate">
|
||||
/// The sum of every non-null per-item estimate, i.e. a rough runtime for a single pass through the
|
||||
/// estimable items. <c>null</c> 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.
|
||||
/// </param>
|
||||
public record ProgramScheduleItemsWithDurationViewModel(
|
||||
List<ProgramScheduleItemViewModel> Items,
|
||||
TimeSpan? TotalDurationEstimate);
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public record GetProgramScheduleItemsWithDurations(int Id)
|
||||
: IRequest<ProgramScheduleItemsWithDurationViewModel>;
|
||||
+70
@@ -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<GetProgramScheduleItemsWithDurations, ProgramScheduleItemsWithDurationViewModel>
|
||||
{
|
||||
public async Task<ProgramScheduleItemsWithDurationViewModel> Handle(
|
||||
GetProgramScheduleItemsWithDurations request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<ProgramScheduleItemViewModel> items =
|
||||
await mediator.Send(new GetProgramScheduleItems(request.Id), cancellationToken);
|
||||
|
||||
Dictionary<int, CollectionDuration> durationsByCollectionId =
|
||||
await AggregateReferencedCollections(items);
|
||||
|
||||
var itemsWithEstimates = items
|
||||
.Map(item => item with { DurationEstimate = Estimate(item, durationsByCollectionId) })
|
||||
.ToList();
|
||||
|
||||
List<TimeSpan> 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<Dictionary<int, CollectionDuration>> AggregateReferencedCollections(
|
||||
IReadOnlyList<ProgramScheduleItemViewModel> items)
|
||||
{
|
||||
List<int> 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<int, CollectionDuration>();
|
||||
foreach (int collectionId in collectionIds)
|
||||
{
|
||||
List<MediaItem> mediaItems = await mediaCollectionRepository.GetItems(collectionId);
|
||||
|
||||
List<TimeSpan> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="ProgramScheduleItemViewModel.DurationEstimate" /> for the
|
||||
/// documented semantics this implements.
|
||||
/// </summary>
|
||||
internal static class ScheduleItemDurationEstimator
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed record CollectionDuration(TimeSpan Total, int ItemCount)
|
||||
{
|
||||
public TimeSpan? Average => ItemCount > 0 ? Total / ItemCount : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimate the runtime of one pass of <paramref name="item" />, or <c>null</c> when no bounded
|
||||
/// estimate is possible. <paramref name="durationsByCollectionId" /> holds aggregates only for the
|
||||
/// plain collections that were resolved; a missing entry yields <c>null</c>.
|
||||
/// </summary>
|
||||
public static TimeSpan? Estimate(
|
||||
ProgramScheduleItemViewModel item,
|
||||
IReadOnlyDictionary<int, CollectionDuration> 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
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
|
||||
namespace ErsatzTV.Application.Watermarks;
|
||||
|
||||
public record GetAllWatermarksForApi : IRequest<List<WatermarkResponseModel>>;
|
||||
@@ -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<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllWatermarksForApi, List<WatermarkResponseModel>>
|
||||
{
|
||||
public async Task<List<WatermarkResponseModel>> Handle(
|
||||
GetAllWatermarksForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<ChannelWatermark> watermarks = await dbContext.ChannelWatermarks
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
return watermarks.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Core.Streaming;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Streaming;
|
||||
|
||||
[TestFixture]
|
||||
public class DirectStreamSessionTrackerTests
|
||||
{
|
||||
[Test]
|
||||
public void Should_Track_Concurrent_Viewers_Per_Channel()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
|
||||
using IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
|
||||
using IDisposable session2 = tracker.Register("1", StreamingMode.HttpLiveStreamingDirect);
|
||||
using IDisposable session3 = tracker.Register("2", StreamingMode.TransportStream);
|
||||
|
||||
tracker.IsActive("1").ShouldBeTrue();
|
||||
tracker.GetViewerCount("1").ShouldBe(2);
|
||||
tracker.GetViewerCount("2").ShouldBe(1);
|
||||
tracker.GetActiveSessions().Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Remove_Only_Disposed_Session()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
|
||||
IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
|
||||
IDisposable session2 = tracker.Register("1", StreamingMode.TransportStream);
|
||||
|
||||
session1.Dispose();
|
||||
|
||||
tracker.IsActive("1").ShouldBeTrue();
|
||||
tracker.GetViewerCount("1").ShouldBe(1);
|
||||
|
||||
session2.Dispose();
|
||||
|
||||
tracker.IsActive("1").ShouldBeFalse();
|
||||
tracker.GetViewerCount("1").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Dispose_Registration_Only_Once()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
|
||||
IDisposable session = tracker.Register("1", StreamingMode.TransportStream);
|
||||
|
||||
session.Dispose();
|
||||
session.Dispose();
|
||||
|
||||
tracker.IsActive("1").ShouldBeFalse();
|
||||
tracker.GetViewerCount("1").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Filter_Active_Sessions_By_Channel()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
|
||||
using IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
|
||||
using IDisposable session2 = tracker.Register("2", StreamingMode.HttpLiveStreamingDirect);
|
||||
|
||||
IReadOnlyCollection<DirectStreamSession> sessions = tracker.GetActiveSessions("2");
|
||||
|
||||
sessions.Count.ShouldBe(1);
|
||||
sessions.Single().ChannelNumber.ShouldBe("2");
|
||||
sessions.Single().StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingDirect);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Orphan_Session_When_Last_Registration_Is_Removed_During_Register()
|
||||
{
|
||||
DirectStreamSessionTracker tracker = null;
|
||||
IDisposable existingSession = null;
|
||||
|
||||
tracker = new TestDirectStreamSessionTracker(() => existingSession?.Dispose());
|
||||
existingSession = tracker.Register("1", StreamingMode.TransportStream);
|
||||
|
||||
using IDisposable newSession = tracker.Register("1", StreamingMode.HttpLiveStreamingDirect);
|
||||
|
||||
tracker.IsActive("1").ShouldBeTrue();
|
||||
tracker.GetViewerCount("1").ShouldBe(1);
|
||||
|
||||
IReadOnlyCollection<DirectStreamSession> sessions = tracker.GetActiveSessions("1");
|
||||
sessions.Count.ShouldBe(1);
|
||||
sessions.Single().StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingDirect);
|
||||
}
|
||||
|
||||
private sealed class TestDirectStreamSessionTracker(Action onRegisteringSession) : DirectStreamSessionTracker
|
||||
{
|
||||
protected override void OnRegisteringSession() => onRegisteringSession();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Artwork;
|
||||
|
||||
/// <summary>
|
||||
/// Result of uploading channel logo / watermark artwork via the REST API.
|
||||
/// <see cref="Path" /> is directly consumable as the <c>Path</c> of an
|
||||
/// <c>ArtworkContentTypeModel</c> (e.g. <c>CreateChannel.Logo</c> / channel update),
|
||||
/// and <see cref="ContentType" /> carries the stored MIME type.
|
||||
/// </summary>
|
||||
public record ArtworkUploadResponseModel(string Path, string ContentType);
|
||||
@@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
/// <summary>A single guide programme for the JSON EPG grid.</summary>
|
||||
public record ChannelGuideProgrammeResponseModel(
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Stop,
|
||||
string Title,
|
||||
string? SubTitle,
|
||||
string? Category,
|
||||
FillerKind FillerKind);
|
||||
|
||||
/// <summary>One channel's guide programmes for the requested window.</summary>
|
||||
public record ChannelGuideChannelResponseModel(
|
||||
string Number,
|
||||
string Name,
|
||||
List<ChannelGuideProgrammeResponseModel> Programmes);
|
||||
|
||||
/// <summary>The JSON channel-guide response: the resolved window plus per-channel programme arrays.</summary>
|
||||
public record ChannelGuideResponseModel(
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset End,
|
||||
List<ChannelGuideChannelResponseModel> Channels);
|
||||
@@ -1,4 +1,5 @@
|
||||
using Newtonsoft.Json;
|
||||
#nullable enable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.Filler;
|
||||
|
||||
public record FillerPresetResponseModel(int Id, string Name);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.Graphics;
|
||||
|
||||
public record GraphicsElementResponseModel(int Id, string Name);
|
||||
@@ -0,0 +1,8 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Health;
|
||||
|
||||
public record HealthCheckResponseModel(
|
||||
string Title,
|
||||
string Status,
|
||||
string Detail,
|
||||
string? Link);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.Libraries;
|
||||
|
||||
public record LibraryScanStatusResponseModel(int LibraryId, decimal Percent);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,9 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record MediaSourceResponseModel(
|
||||
int Id,
|
||||
string Kind,
|
||||
string Name,
|
||||
string? ConnectionAddress,
|
||||
List<MediaSourceLibraryResponseModel> Libraries);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PagedPlayoutItemsResponseModel(
|
||||
int TotalCount,
|
||||
List<PlayoutItemResponseModel> Page);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PagedPlayoutsResponseModel(
|
||||
int TotalCount,
|
||||
List<PlayoutListItemResponseModel> Page);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PlayoutBuildStatusResponseModel(
|
||||
DateTimeOffset LastBuild,
|
||||
bool Success,
|
||||
string Message);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.Watermarks;
|
||||
|
||||
public record WatermarkResponseModel(int Id, string Name);
|
||||
@@ -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<decimal> GetProgress(int libraryId);
|
||||
IReadOnlyList<LibraryScanProgress> GetActiveScans();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Streaming;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Streaming;
|
||||
|
||||
public interface IDirectStreamSessionTracker
|
||||
{
|
||||
IDisposable Register(string channelNumber, StreamingMode streamingMode);
|
||||
bool IsActive(string channelNumber);
|
||||
int GetViewerCount(string channelNumber);
|
||||
IReadOnlyCollection<DirectStreamSession> GetActiveSessions();
|
||||
IReadOnlyCollection<DirectStreamSession> GetActiveSessions(string channelNumber);
|
||||
}
|
||||
@@ -49,4 +49,7 @@ public class ScannerProxyService(IMediator mediator) : IScannerProxyService
|
||||
public Option<decimal> GetProgress(int libraryId) => _activeLibraries.TryGetValue(libraryId, out decimal progress)
|
||||
? progress
|
||||
: Option<decimal>.None;
|
||||
|
||||
public IReadOnlyList<LibraryScanProgress> GetActiveScans() =>
|
||||
_activeLibraries.Select(kvp => new LibraryScanProgress(kvp.Key, kvp.Value)).ToList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Streaming;
|
||||
|
||||
public record DirectStreamSession(
|
||||
Guid Id,
|
||||
string ChannelNumber,
|
||||
StreamingMode StreamingMode,
|
||||
DateTimeOffset StartedAt);
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
|
||||
namespace ErsatzTV.Core.Streaming;
|
||||
|
||||
public class DirectStreamSessionTracker : IDirectStreamSessionTracker
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<Guid, DirectStreamSession>> _sessions = new();
|
||||
|
||||
public IDisposable Register(string channelNumber, StreamingMode streamingMode)
|
||||
{
|
||||
var session = new DirectStreamSession(Guid.NewGuid(), channelNumber, streamingMode, DateTimeOffset.Now);
|
||||
|
||||
ConcurrentDictionary<Guid, DirectStreamSession> channelSessions =
|
||||
_sessions.GetOrAdd(channelNumber, _ => new ConcurrentDictionary<Guid, DirectStreamSession>());
|
||||
|
||||
OnRegisteringSession();
|
||||
|
||||
channelSessions.TryAdd(session.Id, session);
|
||||
|
||||
return new Registration(this, session);
|
||||
}
|
||||
|
||||
protected virtual void OnRegisteringSession()
|
||||
{
|
||||
}
|
||||
|
||||
public bool IsActive(string channelNumber) => GetViewerCount(channelNumber) > 0;
|
||||
|
||||
public int GetViewerCount(string channelNumber) =>
|
||||
_sessions.TryGetValue(channelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions)
|
||||
? channelSessions.Count
|
||||
: 0;
|
||||
|
||||
public IReadOnlyCollection<DirectStreamSession> GetActiveSessions() =>
|
||||
_sessions.Values.SelectMany(s => s.Values).ToList();
|
||||
|
||||
public IReadOnlyCollection<DirectStreamSession> GetActiveSessions(string channelNumber) =>
|
||||
_sessions.TryGetValue(channelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions)
|
||||
? channelSessions.Values.ToList()
|
||||
: [];
|
||||
|
||||
private void Remove(DirectStreamSession session)
|
||||
{
|
||||
if (!_sessions.TryGetValue(session.ChannelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
channelSessions.TryRemove(session.Id, out _);
|
||||
}
|
||||
|
||||
private sealed class Registration(DirectStreamSessionTracker tracker, DirectStreamSession session) : IDisposable
|
||||
{
|
||||
private int _disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
{
|
||||
tracker.Remove(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Extensions;
|
||||
|
||||
public static class PlayoutGuideQueryableExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Eager-loads the full playout-item metadata graph needed to render guide programme
|
||||
/// titles/subtitles/categories/artwork. Shared by the XMLTV cache builder and the JSON guide
|
||||
/// query so both surfaces see identical data. Callers should apply <c>AsSplitQuery()</c>.
|
||||
/// </summary>
|
||||
public static IQueryable<Playout> IncludeGuideMetadata(this IQueryable<Playout> playouts) =>
|
||||
playouts
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Studios)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Directors)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artists)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Studios);
|
||||
}
|
||||
@@ -0,0 +1,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<IImageCache>();
|
||||
_handler = new UploadArtworkHandler(_imageCache);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Logo_Path_With_Iptv_Logos_Prefix()
|
||||
{
|
||||
_imageCache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Logo)
|
||||
.Returns(Right<BaseError, string>("abc123.png"));
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
Either<BaseError, ArtworkUploadResponseModel> 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<Stream>(), ArtworkKind.Watermark)
|
||||
.Returns(Right<BaseError, string>("def456.webp"));
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
Either<BaseError, ArtworkUploadResponseModel> 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<BaseError, ArtworkUploadResponseModel> 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<Stream>(), Arg.Any<ArtworkKind>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Propagate_Cache_Save_Failure()
|
||||
{
|
||||
_imageCache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Logo)
|
||||
.Returns(Left<BaseError, string>(BaseError.New("disk full")));
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
Either<BaseError, ArtworkUploadResponseModel> result = await _handler.Handle(
|
||||
new UploadArtwork(stream, "image/png", ArtworkKind.Logo),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldBe("disk full");
|
||||
}
|
||||
|
||||
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Right: v => v, Left: e => throw new AssertionException($"Expected Right, got Left: {e.Value}"));
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Right: _ => throw new AssertionException("Expected Left, got Right"), Left: e => e);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using DomainChannel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class GetChannelGuideDataHandlerTests
|
||||
{
|
||||
private static readonly DateTime BaseTime = new(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IConfigElementRepository _config = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_config = Substitute.For<IConfigElementRepository>();
|
||||
|
||||
// ConfigElementKey has reference equality and each static accessor returns a fresh instance, so we
|
||||
// match by the generic value type (GetValue<T>) with Arg.Any key. Pin the time zone to UTC so
|
||||
// projected times are deterministic regardless of the machine/CI time zone, and split block time
|
||||
// evenly. XmltvDaysToBuild is left unconfigured (falls back to 2) except in the default-window test.
|
||||
_config.GetValue<XmltvTimeZone>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<XmltvTimeZone>.Some(XmltvTimeZone.Utc));
|
||||
_config.GetValue<XmltvBlockBehavior>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<XmltvBlockBehavior>.Some(XmltvBlockBehavior.SplitTimeEvenly));
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private GetChannelGuideDataHandler MakeHandler() => new(_db.Factory, _config);
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Only_Include_Visible_Channels()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel visible = NewChannel("2", "Visible", showInEpg: true);
|
||||
DomainChannel hidden = NewChannel("3", "Hidden", showInEpg: false);
|
||||
visible.Playouts = [MakeFloodPlayout(visible, (BaseTime, BaseTime.AddHours(1), 1, "Visible Show"))];
|
||||
hidden.Playouts = [MakeFloodPlayout(hidden, (BaseTime, BaseTime.AddHours(1), 1, "Hidden Show"))];
|
||||
context.Channels.AddRange(visible, hidden);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Channels.Select(c => c.Number).ShouldBe(["2"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Order_Channels_By_Decimal_Number()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
context.Channels.Add(NewChannel("10", "Ten", showInEpg: true));
|
||||
context.Channels.Add(NewChannel("2", "Two", showInEpg: true));
|
||||
context.Channels.Add(NewChannel("5.1", "FiveOne", showInEpg: true));
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
// decimal order: 2 < 5.1 < 10 (string order would put "10" first)
|
||||
result.Channels.Select(c => c.Number).ShouldBe(["2", "5.1", "10"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Filter_Programmes_To_Requested_Window()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
|
||||
channel.Playouts =
|
||||
[
|
||||
MakeFloodPlayout(
|
||||
channel,
|
||||
(BaseTime, BaseTime.AddHours(1), 1, "Before Window"),
|
||||
(BaseTime.AddHours(10), BaseTime.AddHours(11), 2, "In Window"))
|
||||
];
|
||||
context.Channels.Add(channel);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// window starts after the first programme has finished
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime.AddHours(5), BaseTime.AddHours(20)),
|
||||
CancellationToken.None);
|
||||
|
||||
List<ChannelGuideProgrammeResponseModel> programmes = result.Channels.Single().Programmes;
|
||||
programmes.Select(p => p.Title).ShouldBe(["In Window"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Passthrough_Custom_Title_And_Null_SubTitle()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
|
||||
PlayoutItem item = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Ignored");
|
||||
item.CustomTitle = "Custom Title";
|
||||
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [item])];
|
||||
context.Channels.Add(channel);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single();
|
||||
programme.Title.ShouldBe("Custom Title");
|
||||
programme.SubTitle.ShouldBeNull();
|
||||
programme.Category.ShouldBe("Movie");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Merge_Leading_PreRoll_Filler_Into_Following_Programme()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
|
||||
PlayoutItem preRoll = MakeItem(BaseTime, BaseTime.AddMinutes(5), guideGroup: 1, movieTitle: "Bumper");
|
||||
preRoll.FillerKind = FillerKind.PreRoll;
|
||||
PlayoutItem content = MakeItem(BaseTime.AddMinutes(5), BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Feature");
|
||||
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [preRoll, content])];
|
||||
context.Channels.Add(channel);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single();
|
||||
// filler is merged: the programme starts at the pre-roll start but displays the feature metadata
|
||||
programme.Title.ShouldBe("Feature");
|
||||
programme.Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero));
|
||||
programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero));
|
||||
programme.FillerKind.ShouldBe(FillerKind.None);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Split_Block_Window_Evenly_Across_Content_Items()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
|
||||
PlayoutItem one = MakeItem(BaseTime, BaseTime.AddMinutes(20), guideGroup: 7, movieTitle: "One");
|
||||
PlayoutItem two = MakeItem(BaseTime.AddMinutes(20), BaseTime.AddMinutes(40), guideGroup: 7, movieTitle: "Two");
|
||||
foreach (PlayoutItem item in new[] { one, two })
|
||||
{
|
||||
item.GuideStart = BaseTime;
|
||||
item.GuideFinish = BaseTime.AddHours(1);
|
||||
}
|
||||
|
||||
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Block, [one, two])];
|
||||
context.Channels.Add(channel);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
List<ChannelGuideProgrammeResponseModel> programmes = result.Channels.Single().Programmes;
|
||||
programmes.Count.ShouldBe(2);
|
||||
// 1-hour guide window split evenly across the 2 content items -> 30 minutes each
|
||||
programmes[0].Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero));
|
||||
programmes[0].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero));
|
||||
programmes[1].Start.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero));
|
||||
programmes[1].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Default_Start_To_Now_And_End_To_DaysToBuild()
|
||||
{
|
||||
_config.GetValue<int>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.Some(3));
|
||||
|
||||
DateTimeOffset before = DateTimeOffset.UtcNow;
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(null, null),
|
||||
CancellationToken.None);
|
||||
DateTimeOffset after = DateTimeOffset.UtcNow;
|
||||
|
||||
result.Start.ShouldBeInRange(before, after);
|
||||
(result.End - result.Start).ShouldBe(TimeSpan.FromDays(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Shift_Mirror_Channel_Programmes_By_PlayoutOffset_Without_Mutating_Source_Items()
|
||||
{
|
||||
PlayoutItem sourceItem = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Live Show");
|
||||
var playoutOffset = TimeSpan.FromHours(3);
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel source = NewChannel("1", "Source", showInEpg: false);
|
||||
source.Playouts = [MakePlayout(source, PlayoutScheduleKind.Classic, [sourceItem])];
|
||||
|
||||
DomainChannel mirror = NewChannel("2", "Mirror", showInEpg: true);
|
||||
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
|
||||
mirror.MirrorSourceChannel = source;
|
||||
mirror.PlayoutOffset = playoutOffset;
|
||||
|
||||
context.Channels.AddRange(source, mirror);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
ChannelGuideProgrammeResponseModel programme = result.Channels.Single(c => c.Number == "2").Programmes.Single();
|
||||
programme.Title.ShouldBe("Live Show");
|
||||
programme.Start.ShouldBe(new DateTimeOffset(BaseTime.Add(playoutOffset), TimeSpan.Zero));
|
||||
programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1).Add(playoutOffset), TimeSpan.Zero));
|
||||
|
||||
// the WithPlayoutOffset copy fix: applying the mirror offset must not mutate the source playout's
|
||||
// own (shared, AsNoTracking) PlayoutItem entities in place.
|
||||
sourceItem.Start.ShouldBe(BaseTime);
|
||||
sourceItem.Finish.ShouldBe(BaseTime.AddHours(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Empty_Programmes_For_Channel_Without_Playout()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
context.Channels.Add(NewChannel("2", "Two", showInEpg: true));
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Channels.Single().Programmes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// --- seeding helpers ---
|
||||
|
||||
private static Playout MakeFloodPlayout(
|
||||
DomainChannel channel,
|
||||
params (DateTime Start, DateTime Finish, int GuideGroup, string Title)[] items) =>
|
||||
MakePlayout(
|
||||
channel,
|
||||
PlayoutScheduleKind.Classic,
|
||||
items.Select(i => MakeItem(i.Start, i.Finish, i.GuideGroup, i.Title)).ToList());
|
||||
|
||||
private static Playout MakePlayout(
|
||||
DomainChannel channel,
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
List<PlayoutItem> items) =>
|
||||
new()
|
||||
{
|
||||
Channel = channel,
|
||||
ScheduleKind = scheduleKind,
|
||||
ScheduleFile = string.Empty,
|
||||
Items = items
|
||||
};
|
||||
|
||||
private static PlayoutItem MakeItem(
|
||||
DateTime start,
|
||||
DateTime finish,
|
||||
int guideGroup,
|
||||
string movieTitle) =>
|
||||
new()
|
||||
{
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
GuideGroup = guideGroup,
|
||||
FillerKind = FillerKind.None,
|
||||
MediaItem = new Movie
|
||||
{
|
||||
MovieMetadata = [new MovieMetadata { Title = movieTitle }],
|
||||
MediaVersions = []
|
||||
}
|
||||
};
|
||||
|
||||
private static DomainChannel NewChannel(string number, string name, bool showInEpg) =>
|
||||
new(Guid.NewGuid())
|
||||
{
|
||||
Number = number,
|
||||
Name = name,
|
||||
Group = "ErsatzTV",
|
||||
Categories = string.Empty,
|
||||
StreamSelector = string.Empty,
|
||||
PreferredAudioLanguageCode = string.Empty,
|
||||
PreferredAudioTitle = string.Empty,
|
||||
PreferredSubtitleLanguageCode = string.Empty,
|
||||
MusicVideoCreditsTemplate = string.Empty,
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
PlayoutMode = ChannelPlayoutMode.Continuous,
|
||||
ShowInEpg = showInEpg,
|
||||
Playouts = []
|
||||
};
|
||||
}
|
||||
@@ -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<FillerPresetResponseModel> 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<FillerPresetResponseModel> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<GraphicsElementResponseModel> 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<GraphicsElementResponseModel> 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<GraphicsElementResponseModel> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<IHealthCheckService>();
|
||||
_handler = new GetAllHealthCheckResultsForApiHandler(_healthCheckService);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Map_Status_Codes_To_Lowercase_Strings()
|
||||
{
|
||||
var results = new List<HealthCheckResult>
|
||||
{
|
||||
new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option<HealthCheckLink>.None),
|
||||
new("Fail Check", HealthCheckStatus.Fail, "broken", "bad", Option<HealthCheckLink>.None),
|
||||
new("Warn Check", HealthCheckStatus.Warning, "watch out", "warn", Option<HealthCheckLink>.None),
|
||||
new("Info Check", HealthCheckStatus.Info, "fyi", "info", Option<HealthCheckLink>.None)
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> 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<HealthCheckResult>
|
||||
{
|
||||
new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option<HealthCheckLink>.None),
|
||||
new("NA Check", HealthCheckStatus.NotApplicable, "skip", "skip", Option<HealthCheckLink>.None)
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> 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<HealthCheckResult>
|
||||
{
|
||||
new(
|
||||
"Linked Check",
|
||||
HealthCheckStatus.Warning,
|
||||
"detail message",
|
||||
"brief",
|
||||
Option<HealthCheckLink>.Some(new HealthCheckLink("https://example.com/docs")))
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> 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<HealthCheckResult>
|
||||
{
|
||||
new("No Link Check", HealthCheckStatus.Pass, "detail", "brief", Option<HealthCheckLink>.None)
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> 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<CancellationToken>())
|
||||
.Returns<Task<List<HealthCheckResult>>>(_ => throw new TaskCanceledException());
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
|
||||
response.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -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<IMediator>();
|
||||
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<LibraryScanStatusResponseModel> result =
|
||||
await handler.Handle(new GetLibraryScanStatus(), CancellationToken.None);
|
||||
|
||||
result.ShouldBe([new LibraryScanStatusResponseModel(42, 62.5m)]);
|
||||
}
|
||||
}
|
||||
@@ -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<MediaItem>()
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
+247
@@ -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<IMediator>();
|
||||
_mediaCollectionRepository = Substitute.For<IMediaCollectionRepository>();
|
||||
}
|
||||
|
||||
[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<GetProgramScheduleItems>(q => q.Id == 99), Arg.Any<CancellationToken>())
|
||||
.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<GetProgramScheduleItems>(q => q.Id == 99), Arg.Any<CancellationToken>())
|
||||
.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 }]
|
||||
};
|
||||
}
|
||||
@@ -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<WatermarkResponseModel> 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<WatermarkResponseModel> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<IMediator>();
|
||||
_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<HttpMethodAttribute>(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<ConsumesAttribute>();
|
||||
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<UnprocessableEntityObjectResult>();
|
||||
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problem.Status.ShouldBe(422);
|
||||
problem.Title.ShouldBe("Validation failed");
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<UnprocessableEntityObjectResult>();
|
||||
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problem.Detail.ShouldContain("maximum allowed size");
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<UnprocessableEntityObjectResult>();
|
||||
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problem.Detail.ShouldContain("Unknown upload target");
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<UploadArtwork>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, ArtworkUploadResponseModel>(
|
||||
new ArtworkUploadResponseModel("iptv/logos/abc.png", "image/png")));
|
||||
|
||||
IActionResult result = await _controller.Upload(file, "logo", CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/iptv/logos/abc.png?contentType=image%2Fpng");
|
||||
created.Value.ShouldBeOfType<ArtworkUploadResponseModel>()
|
||||
.Path.ShouldBe("iptv/logos/abc.png");
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UploadArtwork>(c => c.ArtworkKind == ArtworkKind.Logo && c.ContentType == "image/png"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<UploadArtwork>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, ArtworkUploadResponseModel>(
|
||||
new ArtworkUploadResponseModel("def.webp", "image/webp")));
|
||||
|
||||
IActionResult result = await _controller.Upload(file, "watermark", CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/artwork/watermarks/def.webp?contentType=image%2Fwebp");
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UploadArtwork>(c => c.ArtworkKind == ArtworkKind.Watermark),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Upload_Should_Return_422_On_Handler_Validation_Error()
|
||||
{
|
||||
IFormFile file = MakeFormFile([1, 2, 3], "image/bmp");
|
||||
_mediator.Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, ArtworkUploadResponseModel>(BaseError.New("unsupported content type")));
|
||||
|
||||
IActionResult result = await _controller.Upload(file, "logo", CancellationToken.None);
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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<IBackgroundServiceRequest> _workerChannel = null!;
|
||||
private ChannelController _controller = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
ChannelWriter<IBackgroundServiceRequest> writer =
|
||||
System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
||||
_controller = new ChannelController(writer, _mediator);
|
||||
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
_controller = new ChannelController(_workerChannel.Writer, _mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -162,6 +163,22 @@ public class ChannelControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetGuide_Should_Map_Query_And_Return_Model()
|
||||
{
|
||||
var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var end = new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero);
|
||||
var model = new ChannelGuideResponseModel(start, end, []);
|
||||
_mediator.Send(Arg.Any<GetChannelGuideData>(), Arg.Any<CancellationToken>()).Returns(model);
|
||||
|
||||
ChannelGuideResponseModel result = await _controller.GetGuide(start, end, CancellationToken.None);
|
||||
|
||||
result.ShouldBe(model);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetChannelGuideData>(q => q.Start == start && q.End == end),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
@@ -192,6 +209,19 @@ public class ChannelControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetGuide_Should_Pass_Null_Bounds_Through()
|
||||
{
|
||||
var model = new ChannelGuideResponseModel(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, []);
|
||||
_mediator.Send(Arg.Any<GetChannelGuideData>(), Arg.Any<CancellationToken>()).Returns(model);
|
||||
|
||||
await _controller.GetGuide(null, null, CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetChannelGuideData>(q => q.Start == null && q.End == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkMoveToGroup_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
@@ -266,7 +296,7 @@ public class ChannelControllerTests
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.None);
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("404");
|
||||
IActionResult result = await _controller.ResetPlayout("404", mode: null, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
@@ -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<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.Some(9));
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9, scheduleKind)));
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
var buildPlayout = request.ShouldBeOfType<BuildPlayout>();
|
||||
buildPlayout.PlayoutId.ShouldBe(9);
|
||||
buildPlayout.Mode.ShouldBe(expectedMode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.Some(9));
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
request.ShouldBeOfType<BuildPlayout>().Mode.ShouldBe(PlayoutBuildMode.Continue);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<IMediator>();
|
||||
_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<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/filler-presets");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_FillerPresets()
|
||||
{
|
||||
List<FillerPresetResponseModel> models =
|
||||
[
|
||||
new FillerPresetResponseModel(1, "Intro"),
|
||||
new FillerPresetResponseModel(2, "Outro")
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(models);
|
||||
|
||||
List<FillerPresetResponseModel> 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<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
List<FillerPresetResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.Graphics;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class GraphicsElementControllerTests
|
||||
{
|
||||
private GraphicsElementController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new GraphicsElementController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(GraphicsElementController).GetMethod(nameof(GraphicsElementController.GetAll))
|
||||
?? throw new AssertionException("Missing action GetAll");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/graphics-elements");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_GraphicsElements()
|
||||
{
|
||||
List<GraphicsElementResponseModel> models =
|
||||
[
|
||||
new GraphicsElementResponseModel(1, "Lower Third (lower-third.png)"),
|
||||
new GraphicsElementResponseModel(2, "bug.png")
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(models);
|
||||
|
||||
List<GraphicsElementResponseModel> 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<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
List<GraphicsElementResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.Health;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class HealthControllerTests
|
||||
{
|
||||
private IMediator _mediator = null!;
|
||||
private HealthController _controller = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new HealthController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(HealthController).GetMethod(nameof(HealthController.GetAll))
|
||||
?? throw new AssertionException("Missing action GetAll");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/health");
|
||||
attribute.Name.ShouldBe("GetHealthChecks");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Results_From_Mediator()
|
||||
{
|
||||
var expected = new List<HealthCheckResponseModel>
|
||||
{
|
||||
new("Check One", "pass", "all good", null),
|
||||
new("Check Two", "fail", "broken", "https://example.com")
|
||||
};
|
||||
|
||||
_mediator.Send(Arg.Any<GetAllHealthCheckResultsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(expected);
|
||||
|
||||
List<HealthCheckResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Empty_List_When_None_Exist()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllHealthCheckResultsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
List<HealthCheckResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class LibrariesControllerTests
|
||||
{
|
||||
private LibrariesController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new LibrariesController(Substitute.For<ITelevisionRepository>(), _mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScanStatus_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(LibrariesController).GetMethod(nameof(LibrariesController.GetScanStatus))
|
||||
?? throw new AssertionException("Missing action GetScanStatus");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/libraries/scan-status");
|
||||
attribute.Name.ShouldBe("GetLibraryScanStatus");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetScanStatus_Should_Return_Results_From_Mediator()
|
||||
{
|
||||
var expected = new List<LibraryScanStatusResponseModel>
|
||||
{
|
||||
new(1, 42.5m),
|
||||
new(2, 99m)
|
||||
};
|
||||
|
||||
_mediator.Send(Arg.Any<GetLibraryScanStatus>(), Arg.Any<CancellationToken>())
|
||||
.Returns(expected);
|
||||
|
||||
List<LibraryScanStatusResponseModel> result = await _controller.GetScanStatus(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class MediaSourcesControllerTests
|
||||
{
|
||||
private MediaSourcesController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new MediaSourcesController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(MediaSourcesController).GetMethod(nameof(MediaSourcesController.GetAll))
|
||||
?? throw new AssertionException("Missing action GetAll");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/media-sources");
|
||||
attribute.Name.ShouldBe("GetMediaSources");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Results_From_Mediator()
|
||||
{
|
||||
var expected = new List<MediaSourceResponseModel>
|
||||
{
|
||||
new(
|
||||
1,
|
||||
"Local",
|
||||
"Local",
|
||||
null,
|
||||
[new MediaSourceLibraryResponseModel(10, "Movies", LibraryMediaKind.Movies, null, 3)])
|
||||
};
|
||||
|
||||
_mediator.Send(Arg.Any<GetAllMediaSourcesForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(expected);
|
||||
|
||||
List<MediaSourceResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,8 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/playouts", "post", "422")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "404")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "422")]
|
||||
[TestCase("/api/playouts/{id}/items", "get", "404")]
|
||||
[TestCase("/api/artwork/uploads", "post", "422")]
|
||||
[TestCase("/api/ffmpeg/profiles/{id}", "get", "404")]
|
||||
[TestCase("/api/ffmpeg/profiles", "post", "404")]
|
||||
[TestCase("/api/ffmpeg/profiles", "post", "401")]
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Playouts;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -34,8 +35,12 @@ public class PlayoutControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetAll), "GET", "/api/playouts");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetById), "GET", "/api/playouts/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetItems), "GET", "/api/playouts/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetWarningsCount), "GET", "/api/playouts/warnings/count");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Create), "POST", "/api/playouts");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.ResetAll), "POST", "/api/playouts/reset-all");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/playouts/{id:int}");
|
||||
}
|
||||
|
||||
@@ -162,6 +167,130 @@ public class PlayoutControllerTests
|
||||
problem.Title.ShouldBe("Resource not found");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Project_Paged_List_With_BuildStatus()
|
||||
{
|
||||
var buildStatus = new PlayoutBuildStatus
|
||||
{
|
||||
LastBuild = new DateTimeOffset(2026, 7, 2, 10, 0, 0, TimeSpan.Zero),
|
||||
Success = false,
|
||||
Message = "boom"
|
||||
};
|
||||
PlayoutNameViewModel vm = MakePlayout(9) with
|
||||
{
|
||||
BuildStatus = buildStatus,
|
||||
DbDailyRebuildTime = TimeSpan.FromHours(4)
|
||||
};
|
||||
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedPlayoutsViewModel(1, [vm]));
|
||||
|
||||
PagedPlayoutsResponseModel result = await _controller.GetAll("q", 2, 25, CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
PlayoutListItemResponseModel item = result.Page.Single();
|
||||
item.Id.ShouldBe(9);
|
||||
item.ChannelNumber.ShouldBe("101");
|
||||
item.ChannelName.ShouldBe("Channel");
|
||||
item.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic);
|
||||
item.ScheduleName.ShouldBe("Schedule");
|
||||
item.DailyRebuildTime.ShouldBe(TimeSpan.FromHours(4));
|
||||
item.BuildStatus.ShouldNotBeNull();
|
||||
item.BuildStatus.Success.ShouldBeFalse();
|
||||
item.BuildStatus.Message.ShouldBe("boom");
|
||||
item.BuildStatus.LastBuild.ShouldBe(buildStatus.LastBuild);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetPagedPlayouts>(q => q.Query == "q" && q.PageNum == 2 && q.PageSize == 25),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Emit_Null_BuildStatus_When_Absent()
|
||||
{
|
||||
PlayoutNameViewModel vm = MakePlayout(9) with { BuildStatus = null };
|
||||
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedPlayoutsViewModel(1, [vm]));
|
||||
|
||||
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
|
||||
|
||||
result.Page.Single().BuildStatus.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Project_Items_And_Null_FillerKind_For_Gaps()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
var item = new PlayoutItemViewModel(
|
||||
"Movie",
|
||||
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
|
||||
"1:00:00",
|
||||
string.Empty,
|
||||
Some(FillerKind.MidRoll));
|
||||
var gap = new PlayoutItemViewModel(
|
||||
"UNSCHEDULED",
|
||||
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 2, 13, 30, 0, TimeSpan.Zero),
|
||||
"30:00",
|
||||
string.Empty,
|
||||
Option<FillerKind>.None);
|
||||
_mediator.Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedPlayoutItemsViewModel(2, [item, gap]));
|
||||
|
||||
IActionResult actionResult = await _controller.GetItems(9, showFiller: true, 1, 10, CancellationToken.None);
|
||||
|
||||
var result = actionResult.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
|
||||
result.TotalCount.ShouldBe(2);
|
||||
result.Page[0].Title.ShouldBe("Movie");
|
||||
result.Page[0].Duration.ShouldBe("1:00:00");
|
||||
result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll);
|
||||
result.Page[1].Title.ShouldBe("UNSCHEDULED");
|
||||
result.Page[1].FillerKind.ShouldBeNull();
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetFuturePlayoutItemsById>(q =>
|
||||
q.PlayoutId == 9 && q.ShowFiller && q.PageNum == 1 && q.PageSize == 10),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Return_404_For_Unknown_Playout_With_ProblemDetails()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetItems(404, showFiller: false, 0, 100, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problem.Status.ShouldBe(404);
|
||||
problem.Title.ShouldBe("Resource not found");
|
||||
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetWarningsCount_Should_Return_Count()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutWarningsCount>(), Arg.Any<CancellationToken>())
|
||||
.Returns(7);
|
||||
|
||||
int result = await _controller.GetWarningsCount(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ResetAll_Should_Return_202_And_Send_Command()
|
||||
{
|
||||
IActionResult result = await _controller.ResetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
await _mediator.Received(1).Send(Arg.Any<ResetAllPlayouts>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static PlayoutNameViewModel MakePlayout(int id) =>
|
||||
new(
|
||||
id,
|
||||
@@ -183,7 +312,13 @@ public class PlayoutControllerTests
|
||||
vm.PlayoutMode,
|
||||
vm.ScheduleName,
|
||||
vm.ScheduleFile,
|
||||
vm.DbDailyRebuildTime);
|
||||
vm.DbDailyRebuildTime,
|
||||
vm.BuildStatus is null
|
||||
? null
|
||||
: new PlayoutBuildStatusResponseModel(
|
||||
vm.BuildStatus.LastBuild,
|
||||
vm.BuildStatus.Success,
|
||||
vm.BuildStatus.Message));
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
|
||||
@@ -189,14 +189,18 @@ public class ScheduleControllerTests
|
||||
public async Task GetItems_Should_Return_200_With_Items()
|
||||
{
|
||||
List<ProgramScheduleItemViewModel> items = [MakeOneItem(11)];
|
||||
var response = new ProgramScheduleItemsWithDurationViewModel(items, TimeSpan.FromMinutes(25));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily")));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(items);
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleItemsWithDurations>(), Arg.Any<CancellationToken>())
|
||||
.Returns(response);
|
||||
|
||||
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(items);
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(response);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetProgramScheduleItemsWithDurations>(q => q.Id == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using ErsatzTV.Controllers;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Core.Streaming;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class TrackedFileStreamResultTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Track_Session_Only_While_Result_Executes()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
var stream = new BlockingReadStream();
|
||||
TrackedFileStreamResult result = new(
|
||||
stream,
|
||||
"video/mp2t",
|
||||
tracker,
|
||||
"1",
|
||||
StreamingMode.TransportStream);
|
||||
|
||||
ActionContext context = GetActionContext();
|
||||
|
||||
tracker.IsActive("1").ShouldBeFalse();
|
||||
|
||||
Task execute = result.ExecuteResultAsync(context);
|
||||
await stream.WaitForRead();
|
||||
|
||||
tracker.IsActive("1").ShouldBeTrue();
|
||||
tracker.GetViewerCount("1").ShouldBe(1);
|
||||
|
||||
stream.Complete();
|
||||
await execute;
|
||||
|
||||
tracker.IsActive("1").ShouldBeFalse();
|
||||
tracker.GetViewerCount("1").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Remove_Session_When_Response_Stream_Fails()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
TrackedFileStreamResult result = new(
|
||||
new ThrowingReadStream(new IOException("stream failed")),
|
||||
"video/mp2t",
|
||||
tracker,
|
||||
"1",
|
||||
StreamingMode.HttpLiveStreamingDirect);
|
||||
|
||||
Func<Task> execute = () => result.ExecuteResultAsync(GetActionContext());
|
||||
|
||||
await execute.ShouldThrowAsync<IOException>();
|
||||
|
||||
tracker.IsActive("1").ShouldBeFalse();
|
||||
tracker.GetViewerCount("1").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Remove_Session_When_Response_Stream_Is_Aborted()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
TrackedFileStreamResult result = new(
|
||||
new ThrowingReadStream(new OperationCanceledException("client aborted")),
|
||||
"video/mp2t",
|
||||
tracker,
|
||||
"1",
|
||||
StreamingMode.HttpLiveStreamingDirect);
|
||||
|
||||
await result.ExecuteResultAsync(GetActionContext());
|
||||
|
||||
tracker.IsActive("1").ShouldBeFalse();
|
||||
tracker.GetViewerCount("1").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Track_Session_For_Head_Request()
|
||||
{
|
||||
IDirectStreamSessionTracker tracker = Substitute.For<IDirectStreamSessionTracker>();
|
||||
var stream = new BlockingReadStream();
|
||||
TrackedFileStreamResult result = new(
|
||||
stream,
|
||||
"video/mp2t",
|
||||
tracker,
|
||||
"1",
|
||||
StreamingMode.TransportStream);
|
||||
|
||||
await result.ExecuteResultAsync(GetActionContext(HttpMethods.Head));
|
||||
|
||||
tracker.DidNotReceive().Register(Arg.Any<string>(), Arg.Any<StreamingMode>());
|
||||
}
|
||||
|
||||
private static ActionContext GetActionContext(string method = "GET")
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Method = method;
|
||||
httpContext.RequestServices = new ServiceCollection()
|
||||
.AddLogging()
|
||||
.AddControllers()
|
||||
.Services
|
||||
.BuildServiceProvider();
|
||||
httpContext.Response.Body = new MemoryStream();
|
||||
|
||||
return new ActionContext(httpContext, new RouteData(), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor());
|
||||
}
|
||||
|
||||
private sealed class BlockingReadStream : Stream
|
||||
{
|
||||
private readonly TaskCompletionSource _continue = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _readStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => 0;
|
||||
public override long Position { get; set; }
|
||||
|
||||
public Task WaitForRead() => _readStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
public void Complete() => _continue.SetResult();
|
||||
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_readStarted.TrySetResult();
|
||||
return ReadAfterContinue();
|
||||
}
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
|
||||
ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
private async ValueTask<int> ReadAfterContinue()
|
||||
{
|
||||
await _continue.Task;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingReadStream(Exception exception) : Stream
|
||||
{
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => 0;
|
||||
public override long Position { get; set; }
|
||||
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromException<int>(exception);
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
|
||||
ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw exception;
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class WatermarkControllerTests
|
||||
{
|
||||
private WatermarkController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new WatermarkController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(WatermarkController).GetMethod(nameof(WatermarkController.GetAll))
|
||||
?? throw new AssertionException("Missing action GetAll");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/watermarks");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Watermarks()
|
||||
{
|
||||
List<WatermarkResponseModel> models =
|
||||
[
|
||||
new WatermarkResponseModel(1, "Corner Logo"),
|
||||
new WatermarkResponseModel(2, "Ticker")
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllWatermarksForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(models);
|
||||
|
||||
List<WatermarkResponseModel> 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<GetAllWatermarksForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
List<WatermarkResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.ComponentModel;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Artwork;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class ArtworkUploadController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpPost("/api/artwork/uploads", Name = "UploadArtwork")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[Tags("Artwork")]
|
||||
[EndpointSummary("Upload channel logo or watermark artwork")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ArtworkUploadResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Upload(
|
||||
IFormFile file,
|
||||
[FromForm] [Description("Artwork target: 'logo' (default) or 'watermark'")] string target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
return BaseError.New("A non-empty image file is required").ToErrorResult();
|
||||
}
|
||||
|
||||
long maxBytes = (long)SystemEnvironment.MaximumUploadMb * 1024 * 1024;
|
||||
if (file.Length > maxBytes)
|
||||
{
|
||||
return BaseError.New($"Image exceeds the maximum allowed size of {SystemEnvironment.MaximumUploadMb} MB")
|
||||
.ToErrorResult();
|
||||
}
|
||||
|
||||
if (!TryParseTarget(target, out ArtworkKind artworkKind))
|
||||
{
|
||||
return BaseError.New($"Unknown upload target '{target}'; expected 'logo' or 'watermark'").ToErrorResult();
|
||||
}
|
||||
|
||||
await using Stream stream = file.OpenReadStream();
|
||||
Either<BaseError, ArtworkUploadResponseModel> result = await mediator.Send(
|
||||
new UploadArtwork(stream, file.ContentType, artworkKind),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToCreatedResult(
|
||||
value => LocationFor(artworkKind, value.Path, value.ContentType),
|
||||
value => value);
|
||||
}
|
||||
|
||||
// "logo" (default) and "watermark" are the two channel-artwork surfaces the API exposes today.
|
||||
private static bool TryParseTarget(string target, out ArtworkKind artworkKind)
|
||||
{
|
||||
switch ((target ?? string.Empty).Trim().ToLowerInvariant())
|
||||
{
|
||||
case "":
|
||||
case "logo":
|
||||
artworkKind = ArtworkKind.Logo;
|
||||
return true;
|
||||
case "watermark":
|
||||
artworkKind = ArtworkKind.Watermark;
|
||||
return true;
|
||||
default:
|
||||
artworkKind = ArtworkKind.Logo;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Both GetImage (IptvController) and GetWatermark (ArtworkController) require a contentType
|
||||
// query param to serve the cached file, so the Location header must carry it too.
|
||||
private static string LocationFor(ArtworkKind artworkKind, string path, string contentType)
|
||||
{
|
||||
string encodedContentType = Uri.EscapeDataString(contentType);
|
||||
return artworkKind switch
|
||||
{
|
||||
// logo paths already carry the servable prefix ("iptv/logos/{file}")
|
||||
ArtworkKind.Logo => $"/{path}?contentType={encodedContentType}",
|
||||
_ => $"/artwork/watermarks/{path}?contentType={encodedContentType}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
@@ -28,6 +29,20 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
public async Task<List<ChannelStateResponseModel>> GetState(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelStatesForApi(DateTime.UtcNow), cancellationToken);
|
||||
|
||||
[HttpGet("/api/guide")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get the JSON channel guide (EPG)")]
|
||||
[EndpointDescription(
|
||||
"Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now, " +
|
||||
"and end defaults to start plus the configured XmltvDaysToBuild window.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ChannelGuideResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<ChannelGuideResponseModel> GetGuide(
|
||||
[FromQuery] DateTimeOffset? start,
|
||||
[FromQuery] DateTimeOffset? end,
|
||||
CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get a channel by id")]
|
||||
@@ -144,18 +159,42 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Reset channel playout")]
|
||||
[EndpointDescription(
|
||||
"When mode is omitted, classic playouts use Refresh (rebuild while maintaining collection " +
|
||||
"progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. " +
|
||||
"Pass mode to force a specific PlayoutBuildMode.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ResetPlayout(string channelNumber)
|
||||
public async Task<IActionResult> ResetPlayout(
|
||||
string channelNumber,
|
||||
[FromQuery] PlayoutBuildMode? mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
|
||||
Option<int> maybePlayoutId =
|
||||
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
|
||||
foreach (int playoutId in maybePlayoutId)
|
||||
{
|
||||
await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset));
|
||||
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
|
||||
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// Match Blazor's Playouts.razor reset semantics: classic playouts refresh (preserve progress),
|
||||
// every other kind resets from scratch.
|
||||
private async Task<PlayoutBuildMode> DefaultResetMode(int playoutId, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlayoutNameViewModel> maybePlayout =
|
||||
await mediator.Send(new GetPlayoutById(playoutId), cancellationToken);
|
||||
return maybePlayout.Match(
|
||||
Some: vm => vm.ScheduleKind switch
|
||||
{
|
||||
PlayoutScheduleKind.Classic => PlayoutBuildMode.Refresh,
|
||||
_ => PlayoutBuildMode.Reset
|
||||
},
|
||||
None: () => PlayoutBuildMode.Reset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Application.Filler;
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/filler-presets", Name = "GetFillerPresets")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Get all filler presets")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<FillerPresetResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<FillerPresetResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Application.Graphics;
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class GraphicsElementController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/graphics-elements", Name = "GetGraphicsElements")]
|
||||
[Tags("Graphics Elements")]
|
||||
[EndpointSummary("Get all graphics elements")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<GraphicsElementResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<GraphicsElementResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using ErsatzTV.Application.Health;
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class HealthController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/health", Name = "GetHealthChecks")]
|
||||
[Tags("Health")]
|
||||
[EndpointSummary("Get health check results")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<HealthCheckResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<HealthCheckResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllHealthCheckResultsForApi(), cancellationToken);
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[EndpointGroupName("general")]
|
||||
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator)
|
||||
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/libraries/scan-status", Name = "GetLibraryScanStatus")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Get active library scan status")]
|
||||
[ProducesResponseType(typeof(List<LibraryScanStatusResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<LibraryScanStatusResponseModel>> GetScanStatus(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetLibraryScanStatus(), cancellationToken);
|
||||
|
||||
[HttpPost("/api/libraries/{id:int}/scan")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan library")]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class MediaSourcesController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/media-sources", Name = "GetMediaSources")]
|
||||
[Tags("Media Sources")]
|
||||
[EndpointSummary("Get all media sources with their libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<MediaSourceResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<MediaSourceResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllMediaSourcesForApi(), cancellationToken);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Playouts;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -13,6 +15,32 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/playouts", Name = "GetPlayouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("List playouts")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedPlayoutsResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<PagedPlayoutsResponseModel> GetAll(
|
||||
[FromQuery] string query = "",
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PagedPlayoutsViewModel result =
|
||||
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
|
||||
return new PagedPlayoutsResponseModel(
|
||||
result.TotalCount,
|
||||
result.Page.Map(ToListItemResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Count playouts with a failed build")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(int), StatusCodes.Status200OK)]
|
||||
public async Task<int> GetWarningsCount(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetPlayoutWarningsCount(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get a playout by id")]
|
||||
@@ -25,6 +53,34 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ToResponse).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get upcoming items (and unscheduled gaps) for a playout")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedPlayoutItemsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItems(
|
||||
int id,
|
||||
[FromQuery] bool showFiller = false,
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
PagedPlayoutItemsViewModel result = await mediator.Send(
|
||||
new GetFuturePlayoutItemsById(id, showFiller, pageNum, pageSize),
|
||||
cancellationToken);
|
||||
return new OkObjectResult(
|
||||
new PagedPlayoutItemsResponseModel(
|
||||
result.TotalCount,
|
||||
result.Page.Map(ToItemResponse).ToList()));
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Create a classic playout")]
|
||||
@@ -49,6 +105,17 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Reset all playouts")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playouts/{id:int}")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Delete a playout")]
|
||||
@@ -71,5 +138,32 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
vm.PlayoutMode,
|
||||
vm.ScheduleName,
|
||||
vm.ScheduleFile,
|
||||
vm.DbDailyRebuildTime);
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus));
|
||||
|
||||
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) =>
|
||||
new(
|
||||
vm.PlayoutId,
|
||||
vm.ChannelNumber,
|
||||
vm.ChannelName,
|
||||
vm.ScheduleKind,
|
||||
vm.ScheduleName,
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus));
|
||||
|
||||
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
|
||||
buildStatus is null
|
||||
? null
|
||||
: new PlayoutBuildStatusResponseModel(
|
||||
buildStatus.LastBuild,
|
||||
buildStatus.Success,
|
||||
buildStatus.Message);
|
||||
|
||||
private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) =>
|
||||
new(
|
||||
vm.Title,
|
||||
vm.Start,
|
||||
vm.Finish,
|
||||
vm.Duration,
|
||||
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.Channels;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
@@ -99,8 +99,12 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("/api/schedules/{id:int}/items")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Get schedule items")]
|
||||
[EndpointDescription(
|
||||
"Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a " +
|
||||
"nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " +
|
||||
"derived from referenced collection/media runtimes and are null when unbounded or unknown.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<ProgramScheduleItemViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProgramScheduleItemsWithDurationViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -110,8 +114,8 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<ProgramScheduleItemViewModel> items =
|
||||
await mediator.Send(new GetProgramScheduleItems(id), cancellationToken);
|
||||
ProgramScheduleItemsWithDurationViewModel items =
|
||||
await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken);
|
||||
return new OkObjectResult(items);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class WatermarkController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/watermarks", Name = "GetWatermarks")]
|
||||
[Tags("Watermarks")]
|
||||
[EndpointSummary("Get all watermarks")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<WatermarkResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<WatermarkResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllWatermarksForApi(), cancellationToken);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ namespace ErsatzTV.Controllers;
|
||||
[ServiceFilter(typeof(ConditionalIptvAuthorizeFilter))]
|
||||
public class IptvController : StreamingControllerBase
|
||||
{
|
||||
private readonly IDirectStreamSessionTracker _directStreamSessionTracker;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ILogger<IptvController> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
@@ -33,9 +34,11 @@ public class IptvController : StreamingControllerBase
|
||||
IMediator mediator,
|
||||
IGraphicsEngine graphicsEngine,
|
||||
ILogger<IptvController> logger,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IDirectStreamSessionTracker directStreamSessionTracker)
|
||||
: base(graphicsEngine, logger)
|
||||
{
|
||||
_directStreamSessionTracker = directStreamSessionTracker;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
@@ -150,7 +153,14 @@ public class IptvController : StreamingControllerBase
|
||||
}
|
||||
|
||||
process.Start();
|
||||
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
|
||||
return mode == "ts-legacy"
|
||||
? new TrackedFileStreamResult(
|
||||
process.StandardOutput.BaseStream,
|
||||
"video/mp2t",
|
||||
_directStreamSessionTracker,
|
||||
channelNumber,
|
||||
StreamingMode.TransportStream)
|
||||
: new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
|
||||
},
|
||||
error => BadRequest(error.Value)));
|
||||
}
|
||||
@@ -353,7 +363,11 @@ public class IptvController : StreamingControllerBase
|
||||
|
||||
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
|
||||
|
||||
return GetProcessResponse(result, channelNumber, StreamingMode.HttpLiveStreamingDirect);
|
||||
return GetProcessResponse(
|
||||
result,
|
||||
channelNumber,
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
_directStreamSessionTracker);
|
||||
}
|
||||
|
||||
private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"])
|
||||
|
||||
@@ -16,7 +16,8 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL
|
||||
protected IActionResult GetProcessResponse(
|
||||
Either<BaseError, PlayoutItemProcessModel> result,
|
||||
string channelNumber,
|
||||
StreamingMode mode)
|
||||
StreamingMode mode,
|
||||
IDirectStreamSessionTracker directStreamSessionTracker = null)
|
||||
{
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
@@ -30,14 +31,18 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL
|
||||
|
||||
foreach (PlayoutItemProcessModel processModel in result.RightToSeq())
|
||||
{
|
||||
return StartPlayout(processModel);
|
||||
return StartPlayout(processModel, channelNumber, mode, directStreamSessionTracker);
|
||||
}
|
||||
|
||||
// this will never happen
|
||||
return new NotFoundResult();
|
||||
}
|
||||
|
||||
private FileStreamResult StartPlayout(PlayoutItemProcessModel processModel)
|
||||
private FileStreamResult StartPlayout(
|
||||
PlayoutItemProcessModel processModel,
|
||||
string channelNumber,
|
||||
StreamingMode mode,
|
||||
IDirectStreamSessionTracker directStreamSessionTracker)
|
||||
{
|
||||
// for process counter
|
||||
var ffmpegProcess = new FFmpegProcess();
|
||||
@@ -86,6 +91,10 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL
|
||||
null,
|
||||
TaskScheduler.Default);
|
||||
|
||||
return new FileStreamResult(pipe.Reader.AsStream(), "video/mp2t");
|
||||
Stream stream = pipe.Reader.AsStream();
|
||||
|
||||
return directStreamSessionTracker is null
|
||||
? new FileStreamResult(stream, "video/mp2t")
|
||||
: new TrackedFileStreamResult(stream, "video/mp2t", directStreamSessionTracker, channelNumber, mode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers;
|
||||
|
||||
public class TrackedFileStreamResult(
|
||||
Stream fileStream,
|
||||
string contentType,
|
||||
IDirectStreamSessionTracker directStreamSessionTracker,
|
||||
string channelNumber,
|
||||
StreamingMode streamingMode) : FileStreamResult(fileStream, contentType)
|
||||
{
|
||||
public override async Task ExecuteResultAsync(ActionContext context)
|
||||
{
|
||||
if (HttpMethods.IsHead(context.HttpContext.Request.Method))
|
||||
{
|
||||
await base.ExecuteResultAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
using IDisposable registration = directStreamSessionTracker.Register(channelNumber, streamingMode);
|
||||
|
||||
await base.ExecuteResultAsync(context);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ using ErsatzTV.Core.Scheduling.Engine;
|
||||
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Core.Streaming;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
using ErsatzTV.Core.Troubleshooting;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
@@ -819,6 +820,7 @@ public class Startup
|
||||
services.AddSingleton<IScannerProxyService, ScannerProxyService>();
|
||||
services.AddSingleton<IScriptedPlayoutBuilderService, ScriptedPlayoutBuilderService>();
|
||||
services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>();
|
||||
services.AddSingleton<IDirectStreamSessionTracker, DirectStreamSessionTracker>();
|
||||
services.AddSingleton<ITempFilePool, TempFilePool>();
|
||||
services.AddSingleton<IHlsPlaylistFilter, HlsPlaylistFilter>();
|
||||
services.AddSingleton<RecyclableMemoryStreamManager>();
|
||||
|
||||
+1148
-124
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-amd64 AS dotnet-runtime
|
||||
|
||||
FROM node:22-noble AS web-build
|
||||
FROM node:22-bookworm-slim AS web-build
|
||||
WORKDIR /source
|
||||
COPY web/package*.json ./web/
|
||||
WORKDIR /source/web
|
||||
|
||||
@@ -4,135 +4,132 @@ Paste the prompt below into a fresh session to work the next item. Each session
|
||||
UPDATING THIS FILE in place (rewrite the state section and the queue for the next item) so it
|
||||
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic)
|
||||
filed backend gap issues #100–#111; a 7–9-way parallel workflow build once exhausted RAM, so
|
||||
builds are limited to 2–3 concurrent, never wide fan-outs. Done so far: #97 (PR #112, still
|
||||
stacked on docs/59-ui-redesign-brief), #105 (PR #113), #108 (PR #114), #100+#101+#107+#110
|
||||
(PR #116), #103+#106 (PR #115, Codex), #104 (PR #117), #111 (PR #118), #102 (PR #119 — the
|
||||
last backend gap issue). All backend issues merged to main and closed as of 2026-07-04.
|
||||
builds are limited to 2–3 concurrent, never wide fan-outs. Backend gaps all landed by
|
||||
2026-07-04 (#105/#108/#100+#101+#107+#110/#103+#106/#104/#111/#102 → PRs #113–#119). The
|
||||
MERGE PASS (2026-07-04) then landed the whole SPA stack on main via PR #120.
|
||||
|
||||
**Session state (2026-07-04, post-#102)**: Codex usage is EXHAUSTED — single prompt per
|
||||
session. main is at 6f6f37b7 (post-#119): FULL backend read API + artwork upload + JSON guide.
|
||||
ErsatzTV.Tests on main = 323 tests; ErsatzTV.Core.Tests = 488 (+1 skipped) — all green.
|
||||
No pre-seeded WIP branches remain. Worktree .worktrees/issue-97-channel-state-api still
|
||||
exists for open PR #112 (targets docs/59-ui-redesign-brief); .worktrees/feat-102 is merged
|
||||
(remove it). The docs/59-ui-redesign-brief branch (this file's branch) carries the SPA
|
||||
foundation (web/) + #96/#98 + PR-#112-pending #97 — none of it on main yet → MERGE PASS is
|
||||
the next item.
|
||||
**Session state (2026-07-04, post-#121 merge)**: main = cc6ffcb8 (PR #121 "#99 direct
|
||||
stream session tracking"): design-system/ + web/ SPA foundation (#78–#83), backend
|
||||
#96/#98/#97, Dockerfile hotfix #122, and direct-stream session tracker #99 support are all
|
||||
on main. #97 is closed; #99 remains open for the final `/api/channels/state` wiring only
|
||||
(combine `IFFmpegSegmenterService.IsActive` with `IDirectStreamSessionTracker`). Test
|
||||
baselines on main after #121: ErsatzTV.Tests **368**, Core.Tests **493** (+1 skip); web/:
|
||||
`npm run typecheck` + `build` clean (outputs to ErsatzTV/wwwroot/app/, gitignored; SPA
|
||||
types = web/src/api/generated/v1.d.ts, regen via `npm run generate:api`). Post-merge main
|
||||
run 224 is fully green, including `Build & push image (amd64)`.
|
||||
- **#99 / PR #121 state**: merged after Fable APPROVE. Round-2 fixes removed
|
||||
`DirectStreamSessionTracker` empty-dictionary pruning to avoid orphaned live sessions and
|
||||
bypassed direct-session tracking for HEAD probes in `TrackedFileStreamResult`.
|
||||
- Worktrees: .worktrees/issue-99-session-tracking can be removed after this doc update;
|
||||
issue-97/feat-102 removed. The main checkout still sits on docs/59-ui-redesign-brief —
|
||||
fully merged now, safe to switch to main.
|
||||
|
||||
**Lessons for all remaining prompts** (accumulated from #116/#104/#111/#102 reviews):
|
||||
- DTO records in ErsatzTV.Core/Api MUST get file-scoped `#nullable enable` — with the project
|
||||
default Nullable=disable the regenerated OpenAPI documents every string as `["null","string"]`
|
||||
(breaks SPA typegen with needless `| null`); with it, non-`?` properties emit plain `string`.
|
||||
Precedent: ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs. NOTE (#102): ErsatzTV.Application
|
||||
has NO nullable context — `string?` there trips CS8632; returning null from a plain `string`
|
||||
method is fine and the DTO's `string?` param keeps the spec nullable.
|
||||
- NSubstitute + ConfigElementKey (#102): `ConfigElementKey.X` returns a FRESH instance per
|
||||
access (reference equality) — stubbing `GetValue<T>(ConfigElementKey.X, …)` never matches.
|
||||
Use `Arg.Any<ConfigElementKey>()` disambiguated by the generic `<T>` (precedent:
|
||||
FFmpegProfileHandlerTests).
|
||||
- `Option<T>.ToNullable()` does NOT exist in this LanguageExt version; Option→nullable is
|
||||
`MatchUnsafe(v => (T?)v, () => null)` (idiom: ErsatzTV.Application/Health/Mapper.cs). Plain
|
||||
`Match` throws ResultIsNullException on a null-returning branch.
|
||||
- ./scripts/update-openapi.sh runs `dotnet build -t:GenerateOpenApiDocuments` ONLY — after
|
||||
editing code, run a normal `dotnet build ErsatzTV/ErsatzTV.csproj` FIRST or the script fails
|
||||
with MSB3680.
|
||||
- Id-taking child-collection GETs 404 on unknown parent via a pre-check + ApiResults
|
||||
(precedent: ScheduleController.GetItems, PlayoutController.GetItems) + an
|
||||
OpenApiErrorResponseContractTests [TestCase] entry.
|
||||
- Backlog nits (not filed as issues yet): pageSize is unclamped on all paged endpoints;
|
||||
PlayoutController Create/Delete lack route Name=; PlayoutController.GetItems' existence
|
||||
pre-check reuses GetPlayoutById (3 Includes) instead of a lightweight exists query;
|
||||
uploads >30 MB hit Kestrel's default cap and return a bare 413 (no ProblemDetails);
|
||||
artwork content-type is trusted from the client header (magic-byte sniffing = #66);
|
||||
schedule duration estimator: expression-based `Count` and `Count="0"` both yield null
|
||||
estimates, and it fully materializes each referenced collection per GET.
|
||||
NEW from #102 (also commented on #85): GET /api/guide runs the full 21-include
|
||||
eager-load per channel per request (loads artwork/genres/guids the JSON never uses) —
|
||||
trim the projection or add caching before the EPG grid polls it; and `fillerKind` in the
|
||||
JSON guide is almost always `None` because the shared projector merges filler into
|
||||
adjacent programmes (the no-drift requirement) — discrete filler entries need a JSON-only
|
||||
projection mode (follow-up if #85 wants them).
|
||||
**Lessons for all remaining prompts** (accumulated):
|
||||
- The image-build job runs ONLY on main pushes — PR CI cannot catch Dockerfile breakage.
|
||||
Any Dockerfile change: verify base tags exist (hub.docker.com API) and, where docker is
|
||||
available, `docker build --target <stage>` locally before merging. (`node:22-noble` cost
|
||||
a red main run; node official images have no `-noble` variant.)
|
||||
- DTO records in ErsatzTV.Core/Api MUST get file-scoped `#nullable enable` — else the spec
|
||||
emits `["null","string"]` unions and SPA types get needless `| null`. #96/#98 DTOs were
|
||||
retrofitted in 8d89ab16; ChannelStateResponseModel complied from the start. NOTE:
|
||||
ErsatzTV.Application has NO nullable context — `string?` there trips CS8632.
|
||||
- NSubstitute + ConfigElementKey: `ConfigElementKey.X` is a fresh instance per access —
|
||||
stub with `Arg.Any<ConfigElementKey>()` disambiguated by the generic `<T>`.
|
||||
- `Option<T>.ToNullable()` doesn't exist here; use `MatchUnsafe(v => (T?)v, () => null)`.
|
||||
- ./scripts/update-openapi.sh only runs `-t:GenerateOpenApiDocuments` — do a normal
|
||||
`dotnet build ErsatzTV/ErsatzTV.csproj` FIRST or it fails with MSB3680.
|
||||
- Id-taking child-collection GETs 404 on unknown parent via pre-check + ApiResults
|
||||
(precedent: ScheduleController.GetItems) + an OpenApiErrorResponseContractTests entry.
|
||||
- Backlog nits (unfiled): unclamped pageSize on paged endpoints; PlayoutController
|
||||
Create/Delete lack route Name=; PlayoutController.GetItems existence pre-check is heavy;
|
||||
>30 MB uploads return bare Kestrel 413; artwork content-type trusted from client (#66);
|
||||
schedule duration estimator: expression/zero Count → null estimate, materializes each
|
||||
referenced collection per GET. From #102 (also on #85): GET /api/guide runs the full
|
||||
21-include eager-load per channel per request — trim projection or add caching before the
|
||||
EPG grid polls it; `fillerKind` is almost always `None` (projector merges filler into
|
||||
adjacent programmes) — discrete filler entries need a JSON-only projection mode.
|
||||
|
||||
---
|
||||
|
||||
# PROMPT — MERGE PASS: land the docs/59-ui-redesign-brief SPA stack on main
|
||||
# PROMPT FOR CODEX — #109: Dashboard follow-up — replace stubbed data with real sources
|
||||
|
||||
You are Codex working solo in /Users/timothy/ersatztv (ErsatzTV fork; the React SPA
|
||||
"ChicoryTV" lives in web/ — Vite + TS, typed client in web/src/api/, generated types
|
||||
web/src/api/generated/v1.d.ts). Read CLAUDE.md and docs/contributing.md first; follow the
|
||||
SPA foundation's existing patterns (#78–#83) — match, don't invent.
|
||||
|
||||
You are the ORCHESTRATOR in the main conversation (Fable). Fable is EXPENSIVE — delegate bulk
|
||||
work to cheaper models; use ONE fable subagent only for the final read-only review. This
|
||||
session is mostly git surgery + verification, so much of it is fine inline.
|
||||
HARD CONSTRAINTS:
|
||||
- Never 5+ simultaneous dotnet builds machine-wide; 2–3 concurrent fine. No Workflow tool.
|
||||
- Do NOT regenerate XMLTV/M3U goldens (ETV_UPDATE_GOLDENS) — a golden diff means broken code.
|
||||
- The user may be working in the main checkout (/Users/timothy/ersatztv, currently on
|
||||
docs/59-ui-redesign-brief). Do git surgery in a WORKTREE, and coordinate before checking
|
||||
out branches in the main tree.
|
||||
- Merging PRs needs a fresh one-word "merge" consent from the user per PR.
|
||||
- End by updating this handoff file (see "On completion").
|
||||
- Work in a NEW git worktree: `git worktree add .worktrees/issue-109-dashboard -b
|
||||
feat/109-dashboard-data origin/main` (branch from origin/main; never touch the main
|
||||
checkout or other .worktrees/*).
|
||||
- Max 2–3 concurrent builds machine-wide; keep to ONE dotnet build at a time here.
|
||||
- NEVER set ETV_UPDATE_GOLDENS. A golden-file diff means your code is wrong.
|
||||
- Do NOT merge anything. Open the PR, get CI green, and stop.
|
||||
- Backend scope guard: this is a FRONTEND issue. If a widget needs data no endpoint
|
||||
provides, stub that widget's source cleanly and note it in the PR — do not add endpoints.
|
||||
|
||||
## Project context (read CLAUDE.md first)
|
||||
- Repo: /Users/timothy/ersatztv — ErsatzTV fork (.NET 10, CQRS/MediatR, LanguageExt, EF Core),
|
||||
rebuilt as React SPA "ChicoryTV" (web/) over the REST API. Gitea:
|
||||
http://192.168.1.95:3000/timothy/ersatztv (API auth: basic timothy:ded89Lm4).
|
||||
- main = 6f6f37b7 (post PR #119/#102): complete backend API. ErsatzTV.Tests 323 green;
|
||||
ErsatzTV.Core.Tests 488 green (+1 skipped). Branch protection: PRs need "Build & test
|
||||
(.NET)" green.
|
||||
- docs/59-ui-redesign-brief (a867eeca before this session's doc commits) carries: the #59
|
||||
brief/design docs, design-system/, the SPA foundation web/ (#78–#83), and backend bits
|
||||
#96/#98 that were later ALSO landed on main via the feat/* PRs — expect overlap/conflicts.
|
||||
- PR #112 (issue #97, channel state API) targets docs/59-ui-redesign-brief and is OPEN;
|
||||
its worktree is .worktrees/issue-97-channel-state-api. #97's endpoints may ALSO overlap
|
||||
with what later landed on main — diff before merging.
|
||||
## Context
|
||||
- main = post-#120/#122/#121: full backend read API + SPA foundation + direct-stream
|
||||
session tracking seam. Baselines: ErsatzTV.Tests 368, Core.Tests 493(+1 skip); web
|
||||
`npm run typecheck` + `npm run build` clean.
|
||||
Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
|
||||
- Issue #109: the Dashboard screen (#83) shipped with stubbed data. Replace the stubs with
|
||||
the real sources now on main: /api/health (system health), /api/channels +
|
||||
/api/channels/state (channel counts / on-air), /api/playouts (playout status),
|
||||
/api/version. Read the issue body for the agreed widget list, and locate the stub layer
|
||||
from #83 in web/src/ before writing anything.
|
||||
- CRITICAL (#108 lesson): GET /api/health re-runs all ~14 checks per request (only the
|
||||
warn/error summary is cached) — fetch health ON DEMAND (mount + manual refresh), NOT on a
|
||||
fast poll. If live health is wanted, that's a backend TTL-cache follow-up issue, not this
|
||||
PR. Other widgets may poll gently (≥30s) if the design calls for it.
|
||||
- Note: /api/channels/state `onAir` is still segmenter-session-based until the final #99
|
||||
wiring combines it with `IDirectStreamSessionTracker` — render it as-is; no workarounds.
|
||||
|
||||
## Plan
|
||||
1. RECON [Explore/haiku]: map the divergence — git log/diff main...docs/59-ui-redesign-brief
|
||||
(which commits are docs/web-only, which touch backend files that main since changed);
|
||||
same for PR #112's diff vs main (did #100/#116 already land equivalent channel-state
|
||||
endpoints?). Product: a conflict forecast + recommendation (rebase vs merge main into the
|
||||
stack; whether #112 still adds value or needs slimming to the delta).
|
||||
2. MERGE #112 [inline, after consent]: if it still adds value, merge PR #112 into
|
||||
docs/59-ui-redesign-brief (ask "merge"); else close it with an explanatory comment and
|
||||
cherry-pick any residual delta.
|
||||
3. REBASE/RECONCILE [worktree]: git worktree add .worktrees/merge-pass docs/59-ui-redesign-brief
|
||||
(after user OK, since the main checkout sits on that branch — safer: do the work on a NEW
|
||||
branch, e.g. feat/59-spa-foundation, from the same head). Rebase or merge onto main
|
||||
(recon decides; a single merge commit is acceptable for a long-lived stack). v1.json:
|
||||
take main's, then re-run ./scripts/update-openapi.sh at the end (regen is authoritative).
|
||||
web/ SPA types: regen from the final v1.json (web/ has the typegen script — check
|
||||
web/package.json; main's v1.json now includes everything through /api/guide).
|
||||
4. VERIFY [inline]: dotnet build ErsatzTV.sln; TZ=UTC dotnet test ErsatzTV.Tests +
|
||||
ErsatzTV.Core.Tests; web/: npm ci + typecheck/build if the stack has them.
|
||||
5. REVIEW [fable subagent, read-only]: the RECONCILIATION diff only (what changed vs both
|
||||
parents) — dropped commits, double-applied backend code, stale SPA types, v1.json drift.
|
||||
6. PR the stack → main [inline]: title "feat(web): ChicoryTV SPA foundation (#59 stack)",
|
||||
body listing the SPA issues it closes (#78–#83, #96, #98, #97 if #112 merged), poll CI by
|
||||
head_sha, ask "merge" consent, then verify main's post-merge run. Comment on/close the
|
||||
covered issues per the Task Completion Protocol.
|
||||
7. Cleanup: remove merged worktrees (.worktrees/feat-102, .worktrees/issue-97-* once #112
|
||||
is resolved).
|
||||
## Process
|
||||
1. Comment on issue #109 with findings + approach before coding.
|
||||
2. Implement: swap stubs for typed client calls (follow #81's client/query patterns);
|
||||
loading/error/empty states per the design-system components. No `any` casts around
|
||||
generated types.
|
||||
3. Verify: `cd web && npm ci && npm run typecheck && npm run build`; plus
|
||||
`dotnet build ErsatzTV.sln` and TZ=UTC dotnet test of ErsatzTV.Tests + Core.Tests
|
||||
(sequentially) to prove no backend regression (expect 364 / 488+1skip). If feasible, run
|
||||
the SPA against a live backend (dotnet run + vite dev proxy) and sanity-check the
|
||||
Dashboard renders real data.
|
||||
4. Push, open PR → main: `feat(web): Dashboard real data sources (#109)`, body lists each
|
||||
widget → endpoint mapping and the health fetch policy; `closes #109`. Poll CI by head SHA
|
||||
(/api/v1/repos/timothy/ersatztv/commits/<sha>/status) until green. Do not merge.
|
||||
5. Comment progress on #109 as you go.
|
||||
|
||||
## On completion — REQUIRED last step
|
||||
Update docs/handoffs/chicorytv-issue-queue.md in place: pop the merge pass, write the next
|
||||
prompt (#109 Dashboard follow-up — see queue item 2 and its /api/health polling note), record
|
||||
PR numbers + the new main SHA + where web/ lives now. Commit+push the doc update (to main if
|
||||
the stack landed, else to docs/59-ui-redesign-brief). Print the new prompt in a fenced code
|
||||
block.
|
||||
## On completion — REQUIRED final output
|
||||
Print a fenced handoff prompt addressed to Claude (Fable) asking it to:
|
||||
- Review the PR diff READ-ONLY in one Fable pass (endpoint usage correctness, health
|
||||
polling discipline, type safety, design-system adherence, scope).
|
||||
- Classify findings: NITS Fable may fix directly on the branch; SUBSTANTIAL issues (wrong
|
||||
data contracts, polling violations, state-management flaws) go back to Codex — Fable
|
||||
composes a follow-up Codex prompt with the findings verbatim.
|
||||
- After review: comment the verdict on the PR and #109; on approval + user "merge" consent,
|
||||
merge, verify main's post-merge run (image job included!), then update THIS handoff file
|
||||
(pop #109, next prompt = #84 Channels screen for Codex, record PR number + main SHA +
|
||||
baselines) and push it to main.
|
||||
Include: PR number, branch, head SHA, files changed, widget→endpoint mapping, test/web
|
||||
results, anything deferred or uncertain.
|
||||
|
||||
---
|
||||
|
||||
## Issue queue (work top-down)
|
||||
Single-session (Codex usage exhausted). No pre-seeded WIP branches remain.
|
||||
1. MERGE PASS: PR #112 (#97) + docs/59-ui-redesign-brief stack → main ← PROMPT above
|
||||
(validates SPA issues #78–#83/#96/#98 on main; unblocks all frontend work).
|
||||
2. #109 Dashboard follow-up (frontend; needs merge pass). NOTE from #108: GET /api/health
|
||||
re-runs all ~14 checks per request (only the warn/error summary is cached) — the SPA
|
||||
footer/Dashboard must load on demand or poll gently; add a TTL cache first if it needs to poll.
|
||||
3. Back to UX conversion: #84 Channels (deps #96/#98/#97 available post-merge-pass) → #86
|
||||
Schedule editor (duration estimates from #111) → #87 Playouts → #88 Libraries → #85 EPG
|
||||
(JSON guide from #102 available — mind the fillerKind + per-request-cost notes above) →
|
||||
#89 Channel Builder (artwork upload from #104; also needs #62: #63/#64/#65) → #93 Settings
|
||||
→ #90 rebrand → #91 cutover.
|
||||
Cross-refs: #99 (TS/HLS-Direct sessions) stays backlog; "Definition of Ready" for screen issues
|
||||
lives in the #59 epic body. Done: #105 (PR #113), #108 (PR #114), #100+#101+#107+#110 (PR #116),
|
||||
#103+#106 (PR #115), #104 (PR #117, artwork upload — unblocks #89), #111 (PR #118, schedule item
|
||||
duration estimates — unblocks #86), #102 (PR #119, GET /api/guide + shared ChannelGuideProjector —
|
||||
unblocks #85). Languages-list endpoint from #105 still unimplemented — open a follow-up when
|
||||
#86/#89 need it; filler/watermark lists return DB order — SPA should client-sort.
|
||||
0. HOUSEKEEPING (carry into next session): remove .worktrees/issue-99-session-tracking when
|
||||
no longer needed; #99 remains open for the final `/api/channels/state` onAir wiring.
|
||||
1. #109 Dashboard follow-up ← CODEX PROMPT above (review/merge/doc-update falls to the
|
||||
Fable session that Codex's end-of-run handoff spawns).
|
||||
2. #84 Channels screen (deps #96/#98/#97 on main; live treatment via /api/channels/state —
|
||||
note its onAir is segmenter-only until #99/#121 lands and gets wired).
|
||||
3. UX conversion order: #86 Schedule editor (#111 durations) → #87 Playouts → #88 Libraries
|
||||
→ #85 EPG (#102 JSON guide; mind fillerKind + per-request-cost notes above) → #89 Channel
|
||||
Builder (#104 artwork upload; also needs #62: #63/#64/#65) → #93 Settings → #90 rebrand
|
||||
→ #91 cutover.
|
||||
Cross-refs: #99 direct-session tracking seam landed via PR #121; final channel-state wiring
|
||||
still open on #99. Done this pass: MERGE PASS → PR #120 (closed #97; #78–#83/#96/#98
|
||||
validated on main), hotfix PR #122 (Dockerfile node tag), PR #121 (direct-stream session
|
||||
tracking). Languages-list endpoint from #105 still unimplemented — open a follow-up when
|
||||
#86/#89 need it;
|
||||
filler/watermark lists return DB order — SPA should client-sort.
|
||||
|
||||
+159
-27
@@ -22,7 +22,7 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
mockDashboardApi();
|
||||
});
|
||||
|
||||
it('renders the admin shell chrome and loads the design system stylesheet', () => {
|
||||
it('renders the admin shell chrome and loads the design system stylesheet', async () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole('img', { name: 'ChicoryTV' })).toBeInTheDocument();
|
||||
@@ -31,7 +31,7 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(screen.getByRole('searchbox', { name: 'Search' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Add Channel' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Healthy')).toBeInTheDocument();
|
||||
expect(await screen.findAllByText('Healthy')).toHaveLength(2);
|
||||
expect(designSystemStylesheet).toBe('../../design-system/styles.css');
|
||||
});
|
||||
|
||||
@@ -161,8 +161,8 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
|
||||
expect(await screen.findByText('On air now')).toBeInTheDocument();
|
||||
expect(screen.getByText('System health')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recent activity')).toBeInTheDocument();
|
||||
expect(screen.getByText('Release notes')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Recent activity')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Release notes')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dashboard cards and stats from live API responses', async () => {
|
||||
@@ -185,9 +185,68 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
streamingMode: 'MPEG-TS'
|
||||
}
|
||||
],
|
||||
collections: [{ id: 10, name: 'Saturday Morning' }],
|
||||
schedules: [{ id: 20, name: 'Default Schedule' }],
|
||||
sessions: [{ channelNumber: '5.1' }, { channelNumber: '24' }],
|
||||
channelStates: [
|
||||
{
|
||||
channelId: 1,
|
||||
channelNumber: '5.1',
|
||||
onAir: true,
|
||||
nowPlaying: {
|
||||
finishUtc: '2026-07-04T21:30:00Z',
|
||||
startUtc: '2026-07-04T21:00:00Z',
|
||||
title: 'Saturday Morning Cartoons'
|
||||
}
|
||||
},
|
||||
{
|
||||
channelId: 2,
|
||||
channelNumber: '24',
|
||||
onAir: false,
|
||||
nowPlaying: null
|
||||
}
|
||||
],
|
||||
health: [
|
||||
{
|
||||
detail: 'SQLite is reachable',
|
||||
link: null,
|
||||
status: 'pass',
|
||||
title: 'Database'
|
||||
},
|
||||
{
|
||||
detail: 'FFmpeg path is missing',
|
||||
link: null,
|
||||
status: 'warn',
|
||||
title: 'FFmpeg'
|
||||
}
|
||||
],
|
||||
mediaSources: [
|
||||
{
|
||||
connectionAddress: null,
|
||||
id: 30,
|
||||
kind: 'Local',
|
||||
libraries: [
|
||||
{ id: 31, kind: 'Movies', name: 'Movies' },
|
||||
{ id: 32, kind: 'Shows', name: 'Shows' }
|
||||
],
|
||||
name: 'Local'
|
||||
}
|
||||
],
|
||||
playouts: {
|
||||
page: [
|
||||
{
|
||||
buildStatus: {
|
||||
lastBuild: '2026-07-04T20:00:00Z',
|
||||
message: null,
|
||||
success: true
|
||||
},
|
||||
channelName: 'Retro Cartoons',
|
||||
channelNumber: '5.1',
|
||||
dailyRebuildTime: null,
|
||||
id: 20,
|
||||
scheduleKind: 'Classic',
|
||||
scheduleName: 'Default Schedule'
|
||||
}
|
||||
],
|
||||
totalCount: 1
|
||||
},
|
||||
version: { apiVersion: 3, appVersion: '26.4.0-test' }
|
||||
});
|
||||
|
||||
@@ -196,15 +255,20 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(await screen.findByRole('heading', { name: 'On air now' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
|
||||
expect(screen.getByText('5.1')).toBeInTheDocument();
|
||||
expect(screen.getByText('News 24')).toBeInTheDocument();
|
||||
expect(screen.getByText('24')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 streaming')).toBeInTheDocument();
|
||||
expect(screen.queryByText('News 24')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Saturday Morning Cartoons')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 on air')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('1 warning')).toHaveLength(2);
|
||||
expect(screen.getAllByText('2')).toHaveLength(2);
|
||||
expect(screen.getAllByText('1')).toHaveLength(2);
|
||||
expect(screen.getByText('26.4.0-test')).toBeInTheDocument();
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/channels', expect.any(Object));
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/sessions', expect.any(Object));
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object));
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/media-sources', expect.any(Object));
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/playouts', expect.any(Object));
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/health', expect.any(Object));
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/version', expect.any(Object));
|
||||
expect(window.fetch).not.toHaveBeenCalledWith('/api/sessions', expect.any(Object));
|
||||
expect(window.fetch).not.toHaveBeenCalledWith('/api/schedules', expect.any(Object));
|
||||
});
|
||||
|
||||
it('shows the dashboard loading state while requests are pending', async () => {
|
||||
@@ -215,12 +279,70 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(await screen.findByText('Loading dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty on-air state when the API returns no channels', async () => {
|
||||
mockDashboardApi({ channels: [] });
|
||||
it('shows an empty on-air state when no channel state is on air', async () => {
|
||||
mockDashboardApi({
|
||||
channels: [{ id: 1, name: 'Retro Cartoons', number: '5.1' }],
|
||||
channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: false, nowPlaying: null }]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('No channels found')).toBeInTheDocument();
|
||||
expect(await screen.findByText('No on-air channels reported')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows failing health checks with error styling, distinct from neutral info checks', async () => {
|
||||
mockDashboardApi({
|
||||
health: [
|
||||
{
|
||||
detail: 'SQLite is reachable',
|
||||
link: null,
|
||||
status: 'pass',
|
||||
title: 'Database'
|
||||
},
|
||||
{
|
||||
detail: 'FFmpeg path is missing',
|
||||
link: null,
|
||||
status: 'fail',
|
||||
title: 'FFmpeg'
|
||||
},
|
||||
{
|
||||
detail: 'Scheduled maintenance window active',
|
||||
link: null,
|
||||
status: 'info',
|
||||
title: 'Maintenance'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const { container } = render(<App />);
|
||||
|
||||
expect(await screen.findByText('FFmpeg path is missing')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('1 failing')).toHaveLength(2);
|
||||
expect(container.querySelectorAll('.ctv-health-icon-error')).toHaveLength(1);
|
||||
expect(container.querySelectorAll('.ctv-health-icon-idle').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('refreshes health on demand without polling it', async () => {
|
||||
mockDashboardApi({
|
||||
health: [
|
||||
{
|
||||
detail: 'All checks passed',
|
||||
link: null,
|
||||
status: 'pass',
|
||||
title: 'System'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('All checks passed')).toBeInTheDocument();
|
||||
expect(fetchCount('/api/health')).toBe(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh health' }));
|
||||
|
||||
expect(await screen.findByText('All checks passed')).toBeInTheDocument();
|
||||
expect(fetchCount('/api/health')).toBe(2);
|
||||
});
|
||||
|
||||
it('shows the API error detail when dashboard loading fails', async () => {
|
||||
@@ -305,15 +427,17 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
|
||||
function mockDashboardApi({
|
||||
channels = [],
|
||||
collections = [],
|
||||
schedules = [],
|
||||
sessions = [],
|
||||
channelStates = [],
|
||||
health = [],
|
||||
mediaSources = [],
|
||||
playouts = { page: [], totalCount: 0 },
|
||||
version = { apiVersion: 3, appVersion: '26.4.0' }
|
||||
}: {
|
||||
channels?: unknown[];
|
||||
collections?: unknown[];
|
||||
schedules?: unknown[];
|
||||
sessions?: unknown[];
|
||||
channelStates?: unknown[];
|
||||
health?: unknown[];
|
||||
mediaSources?: unknown[];
|
||||
playouts?: unknown;
|
||||
version?: unknown;
|
||||
} = {}) {
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
@@ -323,16 +447,20 @@ function mockDashboardApi({
|
||||
return Promise.resolve(jsonResponse(channels));
|
||||
}
|
||||
|
||||
if (path === '/api/collections') {
|
||||
return Promise.resolve(jsonResponse(collections));
|
||||
if (path === '/api/channels/state') {
|
||||
return Promise.resolve(jsonResponse(channelStates));
|
||||
}
|
||||
|
||||
if (path === '/api/schedules') {
|
||||
return Promise.resolve(jsonResponse(schedules));
|
||||
if (path === '/api/media-sources') {
|
||||
return Promise.resolve(jsonResponse(mediaSources));
|
||||
}
|
||||
|
||||
if (path === '/api/sessions') {
|
||||
return Promise.resolve(jsonResponse(sessions));
|
||||
if (path === '/api/playouts') {
|
||||
return Promise.resolve(jsonResponse(playouts));
|
||||
}
|
||||
|
||||
if (path === '/api/health') {
|
||||
return Promise.resolve(jsonResponse(health));
|
||||
}
|
||||
|
||||
if (path === '/api/version') {
|
||||
@@ -342,3 +470,7 @@ function mockDashboardApi({
|
||||
return Promise.resolve(jsonResponse(null, 404));
|
||||
});
|
||||
}
|
||||
|
||||
function fetchCount(path: string): number {
|
||||
return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length;
|
||||
}
|
||||
|
||||
+217
-151
@@ -23,11 +23,11 @@ import {
|
||||
ListVideo,
|
||||
Plus,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
TriangleAlert,
|
||||
Tv,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
import chicoryIconUrl from '../../design-system/assets/chicorytv-icon.svg';
|
||||
import {
|
||||
@@ -41,10 +41,17 @@ import {
|
||||
ProgressBar,
|
||||
Spinner,
|
||||
Stat,
|
||||
StatusDot,
|
||||
Tag
|
||||
StatusDot
|
||||
} from './components';
|
||||
import { useChannelsQuery, useDashboardQuery, type DashboardChannel } from './api';
|
||||
import {
|
||||
useChannelsQuery,
|
||||
useDashboardHealthQuery,
|
||||
useDashboardQuery,
|
||||
useDashboardVersionQuery,
|
||||
type DashboardChannel,
|
||||
type DashboardChannelState,
|
||||
type DashboardHealthQueryState
|
||||
} from './api';
|
||||
import {
|
||||
applyDesignSystemTheme,
|
||||
designSystemThemes,
|
||||
@@ -266,11 +273,15 @@ function ThemeSwitcher({
|
||||
|
||||
function Sidebar({
|
||||
activeRoute,
|
||||
healthState,
|
||||
onNavigate
|
||||
}: {
|
||||
activeRoute: ScreenRoute | null;
|
||||
healthState: DashboardHealthQueryState;
|
||||
onNavigate: (route: ScreenRoute, event: MouseEvent) => void;
|
||||
}) {
|
||||
const healthSummary = summarizeHealth(healthState);
|
||||
|
||||
return (
|
||||
<aside className="ctv-sidebar">
|
||||
<div className="ctv-brand">
|
||||
@@ -291,14 +302,28 @@ function Sidebar({
|
||||
<div className="ctv-sidebar-health">
|
||||
<div>
|
||||
<span>ChicoryTV</span>
|
||||
<code>v26.4.0</code>
|
||||
<SidebarVersion />
|
||||
</div>
|
||||
<StatusDot status="ok" label="Healthy" />
|
||||
<StatusDot status={healthSummary.status} label={healthSummary.label} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarVersion() {
|
||||
const versionQuery = useDashboardVersionQuery();
|
||||
|
||||
if (versionQuery.status === 'success') {
|
||||
return <code>{versionQuery.version.appVersion ?? 'version unknown'}</code>;
|
||||
}
|
||||
|
||||
if (versionQuery.status === 'error') {
|
||||
return <code>version unavailable</code>;
|
||||
}
|
||||
|
||||
return <code>loading version</code>;
|
||||
}
|
||||
|
||||
function EndpointRow({ icon, label, url }: { icon: ReactNode; label: string; url: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -445,20 +470,107 @@ function TopBar({ route }: { route: ScreenRoute | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function progressForChannel(channel: DashboardChannel, index: number): number {
|
||||
return ((channel.id * 17 + index * 19) % 72) + 18;
|
||||
type HealthStatus = 'error' | 'idle' | 'live' | 'ok' | 'warn';
|
||||
|
||||
function summarizeHealth(healthState: DashboardHealthQueryState): { label: string; status: HealthStatus } {
|
||||
if (healthState.status === 'loading') {
|
||||
return { label: 'Checking', status: 'idle' };
|
||||
}
|
||||
|
||||
if (healthState.status === 'error') {
|
||||
return { label: 'Health unavailable', status: 'error' };
|
||||
}
|
||||
|
||||
const failedCount = healthState.checks.filter((check) => isErrorHealthStatus(check.status)).length;
|
||||
const warningCount = healthState.checks.filter((check) => isWarningHealthStatus(check.status)).length;
|
||||
|
||||
if (failedCount > 0) {
|
||||
return { label: `${failedCount} failing`, status: 'error' };
|
||||
}
|
||||
|
||||
if (warningCount > 0) {
|
||||
return { label: `${warningCount} warning${warningCount === 1 ? '' : 's'}`, status: 'warn' };
|
||||
}
|
||||
|
||||
return { label: 'Healthy', status: 'ok' };
|
||||
}
|
||||
|
||||
function programForChannel(channel: DashboardChannel): string {
|
||||
const profile = channel.fFmpegProfile ?? channel.streamingMode ?? 'Default profile';
|
||||
|
||||
return `${profile} playout stream`;
|
||||
// The backend serializes health check status as exactly 'pass' | 'fail' | 'warn' | 'info'
|
||||
// (see ErsatzTV.Application/Health/Mapper.cs GetStatus).
|
||||
function isWarningHealthStatus(status: string): boolean {
|
||||
return status.toLowerCase() === 'warn';
|
||||
}
|
||||
|
||||
function OnAirCard({ channel, index }: { channel: DashboardChannel; index: number }) {
|
||||
const name = channel.name ?? 'Unnamed channel';
|
||||
const number = channel.number ?? `${channel.id}`;
|
||||
const progress = progressForChannel(channel, index);
|
||||
function isErrorHealthStatus(status: string): boolean {
|
||||
return status.toLowerCase() === 'fail';
|
||||
}
|
||||
|
||||
function isInfoHealthStatus(status: string): boolean {
|
||||
return status.toLowerCase() === 'info';
|
||||
}
|
||||
|
||||
function healthIconStatus(status: string): HealthStatus {
|
||||
if (isErrorHealthStatus(status)) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (isWarningHealthStatus(status)) {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
if (isInfoHealthStatus(status)) {
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
function healthIcon(status: string): ReactNode {
|
||||
const iconStatus = healthIconStatus(status);
|
||||
|
||||
if (iconStatus === 'error') {
|
||||
return <TriangleAlert aria-hidden="true" size={15} />;
|
||||
}
|
||||
|
||||
if (iconStatus === 'warn') {
|
||||
return <TriangleAlert aria-hidden="true" size={15} />;
|
||||
}
|
||||
|
||||
if (iconStatus === 'idle') {
|
||||
return <Info aria-hidden="true" size={15} />;
|
||||
}
|
||||
|
||||
return <Check aria-hidden="true" size={15} />;
|
||||
}
|
||||
|
||||
function progressFromNowPlaying(nowPlaying: NonNullable<DashboardChannelState['nowPlaying']>): number | null {
|
||||
const start = new Date(nowPlaying.startUtc).getTime();
|
||||
const finish = new Date(nowPlaying.finishUtc).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(finish) || finish <= start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round(((now - start) / (finish - start)) * 100);
|
||||
}
|
||||
|
||||
function minutesUntil(value: string): number | null {
|
||||
const finish = new Date(value).getTime();
|
||||
|
||||
if (!Number.isFinite(finish)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.ceil((finish - Date.now()) / 60000));
|
||||
}
|
||||
|
||||
function OnAirCard({ channel, state }: { channel: DashboardChannel | null; state: DashboardChannelState }) {
|
||||
const name = channel?.name ?? 'Unnamed channel';
|
||||
const number = state.channelNumber || channel?.number || `${state.channelId}`;
|
||||
const nowPlaying = state.nowPlaying;
|
||||
const progress = nowPlaying ? progressFromNowPlaying(nowPlaying) : null;
|
||||
const remaining = nowPlaying ? minutesUntil(nowPlaying.finishUtc) : null;
|
||||
|
||||
return (
|
||||
<div className="ctv-onair-card">
|
||||
@@ -472,11 +584,11 @@ function OnAirCard({ channel, index }: { channel: DashboardChannel; index: numbe
|
||||
On air
|
||||
</Badge>
|
||||
</div>
|
||||
<p>{programForChannel(channel)}</p>
|
||||
<p>{nowPlaying?.title ?? 'Now-playing data unavailable'}</p>
|
||||
<ProgressBar value={progress} />
|
||||
<div className="ctv-onair-meta">
|
||||
<span>{progress}% elapsed</span>
|
||||
<span>{Math.max(4, 60 - progress)}m to next</span>
|
||||
<span>{progress == null ? 'Progress unavailable' : `${Math.max(0, Math.min(100, progress))}% elapsed`}</span>
|
||||
<span>{remaining == null ? 'Finish unavailable' : `${remaining}m to next`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -504,128 +616,81 @@ function DashboardErrorState({ error }: { error: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function HealthPanel({
|
||||
channelCount,
|
||||
sessionCount,
|
||||
apiVersion
|
||||
}: {
|
||||
channelCount: number;
|
||||
sessionCount: number;
|
||||
apiVersion: number;
|
||||
}) {
|
||||
const rows = [
|
||||
{
|
||||
detail: `API v${apiVersion} responding`,
|
||||
icon: <Check aria-hidden="true" size={15} />,
|
||||
label: 'REST API',
|
||||
status: 'ok' as const
|
||||
},
|
||||
{
|
||||
detail: channelCount > 0 ? `${channelCount} channels loaded` : 'No channels returned',
|
||||
icon: channelCount > 0 ? <Check aria-hidden="true" size={15} /> : <Info aria-hidden="true" size={15} />,
|
||||
label: 'Channel catalog',
|
||||
status: channelCount > 0 ? ('ok' as const) : ('warn' as const)
|
||||
},
|
||||
{
|
||||
detail: sessionCount > 0 ? `${sessionCount} active HLS sessions` : 'No active HLS sessions',
|
||||
icon: sessionCount > 0 ? <Zap aria-hidden="true" size={15} /> : <Info aria-hidden="true" size={15} />,
|
||||
label: 'Streaming sessions',
|
||||
status: sessionCount > 0 ? ('live' as const) : ('idle' as const)
|
||||
}
|
||||
];
|
||||
function HealthPanel({ healthState }: { healthState: DashboardHealthQueryState }) {
|
||||
const summary = summarizeHealth(healthState);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<h2>System health</h2>}
|
||||
subtitle="Condensed API status"
|
||||
actions={<Badge tone={rows.some((row) => row.status === 'warn') ? 'warn' : 'ok'}>Live</Badge>}
|
||||
subtitle="Backend health checks"
|
||||
actions={
|
||||
<Button
|
||||
onClick={healthState.refresh}
|
||||
loading={healthState.status === 'loading'}
|
||||
startIcon={<RefreshCw aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Refresh health
|
||||
</Button>
|
||||
}
|
||||
padded={false}
|
||||
>
|
||||
<div className="ctv-health-panel">
|
||||
{rows.map((row) => (
|
||||
<div className="ctv-health-row" key={row.label}>
|
||||
<span className={`ctv-health-icon ctv-health-icon-${row.status}`}>{row.icon}</span>
|
||||
<strong>{row.label}</strong>
|
||||
<span>{row.detail}</span>
|
||||
<StatusDot status={row.status} />
|
||||
{healthState.status === 'loading' && (
|
||||
<div className="ctv-health-row">
|
||||
<span className="ctv-health-icon ctv-health-icon-idle">
|
||||
<Spinner size={15} tone="muted" />
|
||||
</span>
|
||||
<strong>Health checks</strong>
|
||||
<span>Loading current health</span>
|
||||
<StatusDot status="idle" />
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{healthState.status === 'error' && (
|
||||
<div className="ctv-health-row">
|
||||
<span className="ctv-health-icon ctv-health-icon-error">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
</span>
|
||||
<strong>Health checks</strong>
|
||||
<span>{healthState.error}</span>
|
||||
<StatusDot status="error" />
|
||||
</div>
|
||||
)}
|
||||
{healthState.status === 'success' && healthState.checks.length === 0 && (
|
||||
<div className="ctv-health-row">
|
||||
<span className="ctv-health-icon ctv-health-icon-idle">
|
||||
<Info aria-hidden="true" size={15} />
|
||||
</span>
|
||||
<strong>Health checks</strong>
|
||||
<span>No health checks returned</span>
|
||||
<StatusDot status="idle" />
|
||||
</div>
|
||||
)}
|
||||
{healthState.status === 'success' && healthState.checks.map((check) => {
|
||||
const rowStatus = healthIconStatus(check.status);
|
||||
|
||||
return (
|
||||
<div className="ctv-health-row" key={check.title}>
|
||||
<span className={`ctv-health-icon ctv-health-icon-${rowStatus}`}>{healthIcon(check.status)}</span>
|
||||
<strong>{check.title}</strong>
|
||||
<span>{check.detail}</span>
|
||||
<StatusDot status={rowStatus} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="ctv-health-summary">
|
||||
<StatusDot status={summary.status} label={summary.label} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentActivity({
|
||||
channelCount,
|
||||
collectionCount,
|
||||
sessionCount
|
||||
function DashboardScreen({
|
||||
healthState
|
||||
}: {
|
||||
channelCount: number;
|
||||
collectionCount: number;
|
||||
sessionCount: number;
|
||||
healthState: DashboardHealthQueryState;
|
||||
}) {
|
||||
const items = [
|
||||
{
|
||||
icon: <Tv aria-hidden="true" size={13} />,
|
||||
label: 'Channels',
|
||||
text: `Loaded ${channelCount} channels from /api/channels`,
|
||||
time: 'now',
|
||||
tone: 'accent' as const
|
||||
},
|
||||
{
|
||||
icon: <Radio aria-hidden="true" size={13} />,
|
||||
label: 'Sessions',
|
||||
text: `${sessionCount} active streaming sessions reported`,
|
||||
time: 'live',
|
||||
tone: sessionCount > 0 ? ('accent' as const) : ('neutral' as const)
|
||||
},
|
||||
{
|
||||
icon: <FolderTree aria-hidden="true" size={13} />,
|
||||
label: 'Media',
|
||||
text: `${collectionCount} collections available to schedules`,
|
||||
time: 'api',
|
||||
tone: 'neutral' as const
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title={<h2>Recent activity</h2>} subtitle="API-backed dashboard events" padded={false}>
|
||||
<div className="ctv-activity-feed">
|
||||
{items.map((item) => (
|
||||
<div className="ctv-activity-row" key={item.label}>
|
||||
<span className="ctv-activity-icon">{item.icon}</span>
|
||||
<Tag tone={item.tone}>{item.label}</Tag>
|
||||
<span>{item.text}</span>
|
||||
<code>{item.time}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseNotes({ appVersion, apiVersion }: { appVersion: string | null; apiVersion: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<button type="button" className="ctv-release-toggle" onClick={() => setOpen((current) => !current)}>
|
||||
<Sparkles aria-hidden="true" size={15} />
|
||||
<strong>Release notes</strong>
|
||||
<code>{appVersion ?? 'unknown'}</code>
|
||||
<ChevronDown aria-hidden="true" size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ctv-release-body">
|
||||
<p>Running ChicoryTV app version {appVersion ?? 'unknown'} against REST API v{apiVersion}.</p>
|
||||
<p>Release-note content will use the server release feed when it is exposed through the REST API.</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardScreen() {
|
||||
const dashboardQuery = useDashboardQuery();
|
||||
|
||||
if (dashboardQuery.status === 'loading') {
|
||||
@@ -636,45 +701,39 @@ function DashboardScreen() {
|
||||
return <DashboardErrorState error={dashboardQuery.error} />;
|
||||
}
|
||||
|
||||
const { channels, collections, schedules, sessions, version } = dashboardQuery.data;
|
||||
const onAirChannels = channels.slice(0, 4);
|
||||
const { channels, channelStates, mediaSources, playouts } = dashboardQuery.data;
|
||||
const channelsById = new Map(channels.map((channel) => [channel.id, channel]));
|
||||
const onAirStates = channelStates.filter((state) => state.onAir).slice(0, 4);
|
||||
const playoutCount = playouts.totalCount;
|
||||
const libraryCount = mediaSources.reduce((count, source) => count + source.libraries.length, 0);
|
||||
|
||||
return (
|
||||
<div className="ctv-screen-stack">
|
||||
<section className="ctv-stat-row" aria-label="At a glance">
|
||||
<Stat label="Channels" value={channels.length} icon={<Tv aria-hidden="true" size={15} />} />
|
||||
<Stat label="Active playouts" value={schedules.length} icon={<ListVideo aria-hidden="true" size={15} />} />
|
||||
<Stat label="Transcodes" value={sessions.length} icon={<Radio aria-hidden="true" size={15} />} delta={sessions.length > 0 ? 'streaming' : 'idle'} deltaTone="neutral" />
|
||||
<Stat label="Libraries" value={collections.length} icon={<Library aria-hidden="true" size={15} />} />
|
||||
<Stat label="Active playouts" value={playoutCount} icon={<ListVideo aria-hidden="true" size={15} />} />
|
||||
<Stat label="On air" value={onAirStates.length} icon={<Radio aria-hidden="true" size={15} />} />
|
||||
<Stat label="Libraries" value={libraryCount} icon={<Library aria-hidden="true" size={15} />} />
|
||||
</section>
|
||||
|
||||
<section className="ctv-dashboard-grid">
|
||||
<Card
|
||||
title={<h2>On air now</h2>}
|
||||
subtitle="Current programmes by channel"
|
||||
actions={<Badge tone="accent" dot>{sessions.length} streaming</Badge>}
|
||||
actions={<Badge tone="accent" dot>{onAirStates.length} on air</Badge>}
|
||||
>
|
||||
<div className="ctv-onair-grid">
|
||||
{onAirChannels.length > 0 ? (
|
||||
onAirChannels.map((channel, index) => (
|
||||
<OnAirCard channel={channel} index={index} key={channel.id} />
|
||||
{onAirStates.length > 0 ? (
|
||||
onAirStates.map((state) => (
|
||||
<OnAirCard channel={channelsById.get(state.channelId) ?? null} state={state} key={state.channelId} />
|
||||
))
|
||||
) : (
|
||||
<div className="ctv-dashboard-empty">No channels found</div>
|
||||
<div className="ctv-dashboard-empty">No on-air channels reported</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<HealthPanel channelCount={channels.length} sessionCount={sessions.length} apiVersion={version.apiVersion} />
|
||||
</section>
|
||||
|
||||
<section className="ctv-dashboard-grid">
|
||||
<RecentActivity
|
||||
channelCount={channels.length}
|
||||
collectionCount={collections.length}
|
||||
sessionCount={sessions.length}
|
||||
/>
|
||||
<ReleaseNotes appVersion={version.appVersion} apiVersion={version.apiVersion} />
|
||||
<HealthPanel healthState={healthState} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
@@ -725,13 +784,19 @@ function NotFoundScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenContent({ route }: { route: ScreenRoute | null }) {
|
||||
function ScreenContent({
|
||||
healthState,
|
||||
route
|
||||
}: {
|
||||
healthState: DashboardHealthQueryState;
|
||||
route: ScreenRoute | null;
|
||||
}) {
|
||||
if (route === null) {
|
||||
return <NotFoundScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'dashboard') {
|
||||
return <DashboardScreen />;
|
||||
return <DashboardScreen healthState={healthState} />;
|
||||
}
|
||||
|
||||
return <PlaceholderScreen route={route} />;
|
||||
@@ -740,6 +805,7 @@ function ScreenContent({ route }: { route: ScreenRoute | null }) {
|
||||
export function App() {
|
||||
const [theme, setTheme] = useState<DesignSystemThemeId>(() => getStoredDesignSystemTheme());
|
||||
const [activeRoute, setActiveRoute] = useState<ScreenRoute | null>(() => routeFromLocation());
|
||||
const healthState = useDashboardHealthQuery();
|
||||
|
||||
useEffect(() => {
|
||||
applyDesignSystemTheme(theme);
|
||||
@@ -772,12 +838,12 @@ export function App() {
|
||||
|
||||
return (
|
||||
<div className="ctv-app-shell">
|
||||
<Sidebar activeRoute={route} onNavigate={navigate} />
|
||||
<Sidebar activeRoute={route} healthState={healthState} onNavigate={navigate} />
|
||||
<div className="ctv-shell-body">
|
||||
<TopBar route={route} />
|
||||
<main className="ctv-main">
|
||||
<ThemeSwitcher theme={theme} onThemeChange={setTheme} />
|
||||
<ScreenContent route={route} />
|
||||
<ScreenContent healthState={healthState} route={route} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+119
-16
@@ -1,36 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type DashboardChannel = components['schemas']['ChannelResponseModel'];
|
||||
type DashboardCollection = components['schemas']['MediaCollectionViewModel'];
|
||||
type DashboardSchedule = components['schemas']['ProgramScheduleViewModel'];
|
||||
type DashboardSession = components['schemas']['HlsSessionModel'];
|
||||
type DashboardVersion = components['schemas']['CombinedVersion'];
|
||||
export type DashboardChannelState = components['schemas']['ChannelStateResponseModel'];
|
||||
type DashboardHealthCheck = components['schemas']['HealthCheckResponseModel'];
|
||||
type DashboardMediaSource = components['schemas']['MediaSourceResponseModel'];
|
||||
type DashboardPlayouts = components['schemas']['PagedPlayoutsResponseModel'];
|
||||
export type DashboardVersion = components['schemas']['CombinedVersion'];
|
||||
|
||||
export interface DashboardData {
|
||||
channels: DashboardChannel[];
|
||||
collections: DashboardCollection[];
|
||||
schedules: DashboardSchedule[];
|
||||
sessions: DashboardSession[];
|
||||
version: DashboardVersion;
|
||||
channelStates: DashboardChannelState[];
|
||||
mediaSources: DashboardMediaSource[];
|
||||
playouts: DashboardPlayouts;
|
||||
}
|
||||
|
||||
type DashboardQueryState =
|
||||
export type DashboardQueryState =
|
||||
| { data: DashboardData; error: null; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
export type DashboardHealthQueryState =
|
||||
| { checks: DashboardHealthCheck[]; error: null; refresh: () => void; status: 'success' }
|
||||
| { checks: null; error: string; refresh: () => void; status: 'error' }
|
||||
| { checks: null; error: null; refresh: () => void; status: 'loading' };
|
||||
|
||||
export type DashboardVersionQueryState =
|
||||
| { error: null; status: 'success'; version: DashboardVersion }
|
||||
| { error: string; status: 'error'; version: null }
|
||||
| { error: null; status: 'loading'; version: null };
|
||||
|
||||
type DashboardHealthState =
|
||||
| { checks: DashboardHealthCheck[]; error: null; status: 'success' }
|
||||
| { checks: null; error: string; status: 'error' }
|
||||
| { checks: null; error: null; status: 'loading' };
|
||||
|
||||
export async function getDashboardData(): Promise<DashboardData> {
|
||||
const [channels, collections, schedules, sessions, version] = await Promise.all([
|
||||
const [channels, channelStates, mediaSources, playouts] = await Promise.all([
|
||||
request<DashboardChannel[]>('/api/channels'),
|
||||
request<DashboardCollection[]>('/api/collections'),
|
||||
request<DashboardSchedule[]>('/api/schedules'),
|
||||
request<DashboardSession[]>('/api/sessions'),
|
||||
request<DashboardVersion>('/api/version')
|
||||
request<DashboardChannelState[]>('/api/channels/state'),
|
||||
request<DashboardMediaSource[]>('/api/media-sources'),
|
||||
request<DashboardPlayouts>('/api/playouts')
|
||||
]);
|
||||
|
||||
return { channels, collections, schedules, sessions, version };
|
||||
return { channels, channelStates, mediaSources, playouts };
|
||||
}
|
||||
|
||||
export function getDashboardHealth(): Promise<DashboardHealthCheck[]> {
|
||||
return request<DashboardHealthCheck[]>('/api/health');
|
||||
}
|
||||
|
||||
export function getDashboardVersion(): Promise<DashboardVersion> {
|
||||
return request<DashboardVersion>('/api/version');
|
||||
}
|
||||
|
||||
export function useDashboardQuery(): DashboardQueryState {
|
||||
@@ -63,6 +85,87 @@ export function useDashboardQuery(): DashboardQueryState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function useDashboardHealthQuery(): DashboardHealthQueryState {
|
||||
const [state, setState] = useState<DashboardHealthState>({
|
||||
checks: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadHealth = useCallback(() => {
|
||||
getDashboardHealth()
|
||||
.then((checks) => {
|
||||
if (activeRef.current) {
|
||||
setState({ checks, error: null, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ checks: null, error: messageFromError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setState({ checks: null, error: null, status: 'loading' });
|
||||
loadHealth();
|
||||
}, [loadHealth]);
|
||||
|
||||
useEffect(() => {
|
||||
loadHealth();
|
||||
}, [loadHealth]);
|
||||
|
||||
if (state.status === 'success') {
|
||||
return { checks: state.checks, error: null, refresh, status: 'success' };
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { checks: null, error: state.error, refresh, status: 'error' };
|
||||
}
|
||||
|
||||
return { checks: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
export function useDashboardVersionQuery(): DashboardVersionQueryState {
|
||||
const [state, setState] = useState<DashboardVersionQueryState>({
|
||||
error: null,
|
||||
status: 'loading',
|
||||
version: null
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
getDashboardVersion()
|
||||
.then((version) => {
|
||||
if (active) {
|
||||
setState({ error: null, status: 'success', version });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (active) {
|
||||
setState({ error: messageFromError(error), status: 'error', version: null });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function messageFromError(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
|
||||
Vendored
+111
-15
@@ -21,20 +21,42 @@ export interface components {
|
||||
"isExternalUrl"?: boolean;
|
||||
"hasContentType"?: boolean;
|
||||
"urlWithContentType"?: null | string;
|
||||
};
|
||||
"ArtworkUploadResponseModel": {
|
||||
"path": string;
|
||||
"contentType": string;
|
||||
};
|
||||
"BulkDeleteChannelsRequest": {
|
||||
"channelIds": null | Array<number>;
|
||||
"channelIds": Array<number>;
|
||||
};
|
||||
"BulkMoveChannelsToGroupRequest": {
|
||||
"channelIds": null | Array<number>;
|
||||
"group": null | string;
|
||||
"channelIds": Array<number>;
|
||||
"group": string;
|
||||
};
|
||||
"BulkRenumberChannelRequest": {
|
||||
"id": number;
|
||||
"number": null | string;
|
||||
"number": string;
|
||||
};
|
||||
"BulkRenumberChannelsRequest": {
|
||||
"channels": null | Array<components["schemas"]["BulkRenumberChannelRequest"]>;
|
||||
"channels": Array<components["schemas"]["BulkRenumberChannelRequest"]>;
|
||||
};
|
||||
"ChannelGuideChannelResponseModel": {
|
||||
"number": string;
|
||||
"name": string;
|
||||
"programmes": Array<components["schemas"]["ChannelGuideProgrammeResponseModel"]>;
|
||||
};
|
||||
"ChannelGuideProgrammeResponseModel": {
|
||||
"start": string;
|
||||
"stop": string;
|
||||
"title": string;
|
||||
"subTitle": null | string;
|
||||
"category": null | string;
|
||||
"fillerKind": components["schemas"]["FillerKind"];
|
||||
};
|
||||
"ChannelGuideResponseModel": {
|
||||
"start": string;
|
||||
"end": string;
|
||||
"channels": Array<components["schemas"]["ChannelGuideChannelResponseModel"]>;
|
||||
};
|
||||
"ChannelIdleBehavior": "StopOnDisconnect" | "KeepRunning";
|
||||
"ChannelMusicVideoCreditsMode": "None" | "GenerateSubtitles";
|
||||
@@ -47,14 +69,14 @@ export interface components {
|
||||
"ChannelPlayoutSource": "Generated" | "Mirror";
|
||||
"ChannelResponseModel": {
|
||||
"id": number;
|
||||
"number": null | string;
|
||||
"number": string;
|
||||
"sortNumber": number;
|
||||
"name": null | string;
|
||||
"group": null | string;
|
||||
"categories": null | string;
|
||||
"fFmpegProfile": null | string;
|
||||
"language": null | string;
|
||||
"streamingMode": null | string;
|
||||
"name": string;
|
||||
"group": string;
|
||||
"categories": string;
|
||||
"fFmpegProfile": string;
|
||||
"language": string;
|
||||
"streamingMode": string;
|
||||
"isEnabled": boolean;
|
||||
"showInEpg": boolean;
|
||||
};
|
||||
@@ -221,6 +243,10 @@ export interface components {
|
||||
"FFmpegProfileVideoFormat": "None" | "H264" | "Hevc" | "Mpeg2Video" | "Av1" | "Copy";
|
||||
"FillerKind": "None" | "PreRoll" | "MidRoll" | "PostRoll" | "Tail" | "Fallback" | "GuideMode" | "DecoDefault";
|
||||
"FillerMode": "None" | "Duration" | "Count" | "Pad" | "RandomCount";
|
||||
"FillerPresetResponseModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
};
|
||||
"FillerPresetViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
@@ -242,6 +268,10 @@ export interface components {
|
||||
"FillWithGroupMode": "None" | "FillWithOrderedGroups" | "FillWithShuffledGroups";
|
||||
"FilterMode": "HardwareIfPossible" | "Software";
|
||||
"FixedStartTimeBehavior": "Strict" | "Flexible";
|
||||
"GraphicsElementResponseModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
};
|
||||
"GraphicsElementViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
@@ -249,11 +279,23 @@ export interface components {
|
||||
};
|
||||
"GuideMode": "Normal" | "Filler";
|
||||
"HardwareAccelerationKind": "None" | "Qsv" | "Nvenc" | "Vaapi" | "VideoToolbox" | "Amf" | "V4l2m2m" | "Rkmpp";
|
||||
"HealthCheckResponseModel": {
|
||||
"title": string;
|
||||
"status": string;
|
||||
"detail": string;
|
||||
"link": null | string;
|
||||
};
|
||||
"HlsSessionModel": {
|
||||
"channelNumber": null | string;
|
||||
"state": null | string;
|
||||
"transcodedUntil": string;
|
||||
"lastAccess": string;
|
||||
};
|
||||
"IFormFile": string;
|
||||
"LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams";
|
||||
"LibraryScanStatusResponseModel": {
|
||||
"libraryId": number;
|
||||
"percent": number;
|
||||
};
|
||||
"MarathonGroupBy": "None" | "Show" | "Season" | "Artist" | "Album" | "Director";
|
||||
"MediaCollectionViewModel": {
|
||||
@@ -270,6 +312,20 @@ export interface components {
|
||||
"hasMediaInfo"?: boolean;
|
||||
};
|
||||
"MediaItemState": "Normal" | "FileNotFound" | "Unavailable" | "RemoteOnly";
|
||||
"MediaSourceLibraryResponseModel": {
|
||||
"id": number;
|
||||
"name": string;
|
||||
"mediaKind": components["schemas"]["LibraryMediaKind"];
|
||||
"lastScan": null | string;
|
||||
"itemCount": number;
|
||||
};
|
||||
"MediaSourceResponseModel": {
|
||||
"id": number;
|
||||
"kind": string;
|
||||
"name": string;
|
||||
"connectionAddress": null | string;
|
||||
"libraries": Array<components["schemas"]["MediaSourceLibraryResponseModel"]>;
|
||||
};
|
||||
"MultiCollectionItemViewModel": {
|
||||
"multiCollectionId": number;
|
||||
"collection": components["schemas"]["MediaCollectionViewModel"];
|
||||
@@ -294,23 +350,54 @@ export interface components {
|
||||
"name": null | string;
|
||||
};
|
||||
"NormalizeLoudnessMode": "Off" | "LoudNorm";
|
||||
"PagedPlayoutItemsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": null | Array<components["schemas"]["PlayoutItemResponseModel"]>;
|
||||
};
|
||||
"PagedPlayoutsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": null | Array<components["schemas"]["PlayoutListItemResponseModel"]>;
|
||||
};
|
||||
"PlaybackOrder": "None" | "Chronological" | "Random" | "Shuffle" | "ShuffleInOrder" | "MultiEpisodeShuffle" | "SeasonEpisode" | "RandomRotation" | "Marathon";
|
||||
"PlaylistViewModel": {
|
||||
"id": number;
|
||||
"playlistGroupId": number;
|
||||
"name": null | string;
|
||||
"isSystem": boolean;
|
||||
};
|
||||
"PlayoutBuildMode": "Continue" | "Refresh" | "Reset";
|
||||
"PlayoutBuildStatusResponseModel": {
|
||||
"lastBuild": string;
|
||||
"success": boolean;
|
||||
"message": null | string;
|
||||
};
|
||||
"PlayoutItemResponseModel": {
|
||||
"title": null | string;
|
||||
"start": string;
|
||||
"finish": string;
|
||||
"duration": null | string;
|
||||
"fillerKind": null | components["schemas"]["FillerKind"];
|
||||
};
|
||||
"PlayoutListItemResponseModel": {
|
||||
"id": number;
|
||||
"channelNumber": string;
|
||||
"channelName": string;
|
||||
"scheduleKind": components["schemas"]["PlayoutScheduleKind"];
|
||||
"scheduleName": string;
|
||||
"dailyRebuildTime": null | string;
|
||||
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
|
||||
};
|
||||
"PlayoutMode": "Flood" | "One" | "Multiple" | "Duration";
|
||||
"PlayoutResponseModel": {
|
||||
"id": number;
|
||||
"scheduleKind": components["schemas"]["PlayoutScheduleKind"];
|
||||
"channelName": null | string;
|
||||
"channelNumber": null | string;
|
||||
"channelName": string;
|
||||
"channelNumber": string;
|
||||
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
|
||||
"scheduleName": null | string;
|
||||
"scheduleName": string;
|
||||
"scheduleFile": null | string;
|
||||
"dailyRebuildTime": null | string;
|
||||
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
|
||||
};
|
||||
"PlayoutScheduleKind": "None" | "Classic" | "Block" | "Sequential" | "Scripted" | "ExternalJson";
|
||||
"ProblemDetails": {
|
||||
@@ -319,6 +406,10 @@ export interface components {
|
||||
"status"?: null | number;
|
||||
"detail"?: null | string;
|
||||
"instance"?: null | string;
|
||||
};
|
||||
"ProgramScheduleItemsWithDurationViewModel": {
|
||||
"items": null | Array<components["schemas"]["ProgramScheduleItemViewModel"]>;
|
||||
"totalDurationEstimate": null | string;
|
||||
};
|
||||
"ProgramScheduleItemViewModel": {
|
||||
"id"?: number;
|
||||
@@ -355,6 +446,7 @@ export interface components {
|
||||
"preferredAudioTitle"?: null | string;
|
||||
"preferredSubtitleLanguageCode"?: null | string;
|
||||
"subtitleMode"?: null | components["schemas"]["ChannelSubtitleMode"];
|
||||
"durationEstimate"?: null | string;
|
||||
"name"?: null | string;
|
||||
};
|
||||
"ProgramScheduleViewModel": {
|
||||
@@ -523,6 +615,10 @@ export interface components {
|
||||
};
|
||||
"VaapiDriver": "Default" | "iHD" | "i965" | "RadeonSI" | "Nouveau";
|
||||
"WatermarkLocation": number;
|
||||
"WatermarkResponseModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
};
|
||||
"WatermarkSize": number;
|
||||
"WatermarkViewModel": {
|
||||
"id": number;
|
||||
|
||||
+14
-65
@@ -442,7 +442,6 @@
|
||||
}
|
||||
|
||||
.ctv-onair-head code,
|
||||
.ctv-activity-list code,
|
||||
.ctv-live-channel-row code {
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
@@ -516,8 +515,7 @@
|
||||
gap: var(--space-7, 16px);
|
||||
}
|
||||
|
||||
.ctv-health-panel,
|
||||
.ctv-activity-feed {
|
||||
.ctv-health-panel {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@@ -535,8 +533,7 @@
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.ctv-health-icon,
|
||||
.ctv-activity-icon {
|
||||
.ctv-health-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
@@ -555,6 +552,14 @@
|
||||
color: var(--status-warn);
|
||||
}
|
||||
|
||||
.ctv-health-icon-error {
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.ctv-health-icon-idle {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ctv-health-icon-live {
|
||||
color: var(--status-live);
|
||||
}
|
||||
@@ -571,67 +576,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ctv-activity-row {
|
||||
min-height: 44px;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-4, 8px);
|
||||
.ctv-health-summary {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--border-hairline);
|
||||
padding: 0 var(--space-6, 12px);
|
||||
}
|
||||
|
||||
.ctv-activity-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.ctv-activity-row > span:nth-child(3) {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ctv-activity-row code,
|
||||
.ctv-release-toggle code {
|
||||
color: var(--text-disabled);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: var(--text-2xs, 11px);
|
||||
}
|
||||
|
||||
.ctv-release-toggle {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-4, 8px);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ctv-release-toggle svg:first-child {
|
||||
color: var(--action-primary);
|
||||
}
|
||||
|
||||
.ctv-release-toggle code {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.ctv-release-body {
|
||||
display: grid;
|
||||
gap: var(--space-3, 6px);
|
||||
margin-top: var(--space-6, 12px);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs, 12px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ctv-release-body p {
|
||||
margin: 0;
|
||||
padding: var(--space-4, 8px) var(--space-6, 12px);
|
||||
}
|
||||
|
||||
.ctv-live-channel-body {
|
||||
|
||||
Reference in New Issue
Block a user