Compare commits

..
Author SHA1 Message Date
timothyandClaude Fable 5 73455aae28 fix(api): surface programs not filler in channel state, tighten queries
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review fixes for the #97 endpoint:

- nowPlaying now resolves like the XMLTV guide: when the wall-clock item
  is pre/mid-roll filler, surface the guide group's program item (title +
  full program bounds) instead of the filler; a guide entry with no
  program item (e.g. fallback loop) reports null. Previously an ad break
  would retitle the channel and reset progress every 30 seconds.
- covering-item lookup now runs one cheap projected query per distinct
  playout offset instead of hydrating the full metadata include tree for
  a window widened by the largest offset across ALL channels (up to 24h);
  metadata is fetched in a second query scoped to the covering guide
  groups, time-bounded to stay safe against GuideGroup recycling (mod
  10000). Regression caught while testing: covering items are keyed by
  (source channel, lookup time) so a source channel no longer inherits
  its offset mirror's lookup result.
- GetChannelStatesForApi now carries Now explicitly (controller passes
  DateTime.UtcNow), making boundary semantics testable: new tests pin
  now == Start (playing) and now == Finish (not playing), plus mid-roll,
  filler-only, and mirror-source-row cases.
- ChannelNowPlayingResponseModel title is non-nullable in the schema
  (#nullable enable) — GetDisplayTitle always returns a string.
- GetState uses the typed-return controller convention; Playouts.Mapper
  reverted to internal (handler lives in the same assembly).
- Spec doc notes the OnDemand drift caveat and filler semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:53:21 +02:00
timothyandClaude Fable 5 e66f49e54a fix(api): make channel state NowPlaying nullable in DTO
An off-air channel has no current playout item, so the handler returns
null for NowPlaying; the DTO now declares that (file-scoped #nullable —
ErsatzTV.Core has NRT disabled project-wide). Regenerated OpenAPI and
web types: nowPlaying keeps its nullable oneOf schema (matching the
schema contract test) and channelNumber loses a spurious nullable
annotation left over from an earlier draft — the field is required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:00 +02:00
timothy 07e9d9838d fix(api): resolve mirror channel now playing state 2026-07-02 20:02:01 +02:00
timothy f01ffc6254 test(api): enforce nullable channel now playing schema 2026-07-02 19:50:19 +02:00
timothy 6057bcea7d docs(api): document channel state endpoint 2026-07-02 19:45:53 +02:00
timothy 7b903d4bb4 feat(api): return channel runtime state 2026-07-02 19:37:14 +02:00
timothy 7c1a1d5672 feat(api): add channel state endpoint contract 2026-07-02 19:29:59 +02:00
timothy 1891498563 chore: ignore local worktrees 2026-07-02 19:24:06 +02:00
timothy 7484df1e5b docs(api): plan channel state endpoint 2026-07-02 19:20:50 +02:00
timothy 52bfdbcdce docs(api): specify channel state endpoint 2026-07-02 19:16:07 +02:00
timothy 6b3a698283 feat(api): add bulk channel operations
fixes #98
2026-07-02 19:07:35 +02:00
timothy 32c0d6a6d3 feat(api): enrich channel list response 2026-07-02 18:56:05 +02:00
timothy 97b6c4bee7 fix(web): harden spa foundation
refs #95
2026-07-02 18:36:56 +02:00
timothy 032b9d8116 fix(web): address app shell review findings
refs #94
2026-07-02 18:33:16 +02:00
timothy 6e6fd67fea feat(web): implement ChicoryTV dashboard
refs #83
2026-07-02 18:29:19 +02:00
timothy 5fb3bb2ebc feat(web): add ChicoryTV app shell
refs #82
2026-07-02 18:21:59 +02:00
timothy d1cc12e065 feat(web): add typed API client auth
refs #81
2026-07-02 08:23:19 +02:00
timothy e6df6ac10a feat(web): add ChicoryTV component primitives
refs #80
2026-07-02 08:09:09 +02:00
timothy e282bcecfa feat(frontend): wire ChicoryTV SPA themes
refs #79
2026-07-02 07:56:12 +02:00
timothy b183c6a154 feat(frontend): scaffold ChicoryTV SPA
refs #78
2026-07-02 07:37:00 +02:00
timothyandClaude Opus 4.8 c0b20a0ac5 docs: add auto-tuning screen + borrowed-concept enrichments to brief
Adds the Auto-tuning wizard as hero screen 5 (#69) and annotates the
Channels-list and Schedule-editor sections + prompts with the borrowed
enrichments: lineup health + resume badge (#72, #68), weighted
distribution / reroll / pad-to-boundary / seasonal (#70, #71, #77, #73).

Refs #59 #69

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:45:07 +02:00
timothyandClaude Opus 4.8 482396497e docs: link Create Channel backend gaps to epic #62 (#63-#68)
Refs #59 #62

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:02:48 +02:00
timothyandClaude Opus 4.8 4229717575 docs: add Create Channel workflow redesign + backend gaps
Replaces the inherited 4-editor channel-creation chain with a single
library-to-lineup builder: browse/search library with artwork, drag
shows into the lineup, friendly toggles (shuffle, always-on), one image
for logo+bug, technical settings via built-in/custom Channel Templates
with advanced override. Lists prerequisite backend gaps (composite
create endpoint, Channel Templates, library browse API, full image-type
support incl. transparent re-encode, unified logo/bug, always-on vs
on-demand) and a Claude Design prompt for the screen.

Refs #59

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:00:09 +02:00
timothyandClaude Opus 4.8 0826198d21 docs: add ChicoryTV UI redesign visual-direction brief (Pass 1)
Records the locked design direction for #59: React SPA on the REST API,
API-first (design pulls the API), Komodo-slick look, ChicoryTV dark +
chicory-blue brand. Includes the 4 hero screens with real domain
vocabulary and ready-to-paste Claude Design prompts.

Refs #59

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:58:12 +02:00
246 changed files with 25311 additions and 4604 deletions
+31
View File
@@ -50,6 +50,37 @@ jobs:
- name: Restore
run: dotnet restore
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22.x'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install SPA dependencies
working-directory: web
run: npm ci
- name: Check generated SPA API client
working-directory: web
run: npm run check:api
- name: Lint SPA
working-directory: web
run: npm run lint
- name: Typecheck SPA
working-directory: web
run: npm run typecheck
- name: Test SPA
working-directory: web
run: npm test -- --run
- name: Build SPA
working-directory: web
run: npm run build
- name: Strip Scanner project ref (matches Docker build)
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
+4
View File
@@ -3,6 +3,7 @@
project.lock.json
.DS_Store
*.pyc
.worktrees/
# Claude Code
.mcp/
@@ -51,3 +52,6 @@ scripts/download-test-content.sh
docker-compose.override.yml
ErsatzTV/wwwroot/v2/
ErsatzTV/wwwroot/app/
web/dist/
web/node_modules/
-15
View File
@@ -1,15 +0,0 @@
# 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.
@@ -1,13 +0,0 @@
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>>;
@@ -1,53 +0,0 @@
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
};
}
@@ -1,71 +0,0 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Shared programme-metadata projection for guide output. Both the XMLTV cache builder
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
/// (<see cref="GetChannelGuideDataHandler" />) resolve the display title/subtitle/category from a
/// <see cref="PlayoutItem" /> here so the two representations stay consistent.
/// </summary>
public static class ChannelGuideMetadata
{
public static string GetTitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return playoutItem.CustomTitle;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]"
};
}
public static string GetSubtitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
Song s => s.SongMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
/// <summary>
/// The primary guide category, mirroring the fixed <c>&lt;category&gt;</c> the XMLTV templates
/// emit per media kind (Movie / Series / Music). Media kinds without a fixed category return null.
/// </summary>
public static string GetCategory(PlayoutItem playoutItem) =>
playoutItem.MediaItem switch
{
Movie => "Movie",
Episode => "Series",
MusicVideo => "Music",
Song => "Music",
_ => null
};
}
@@ -1,164 +0,0 @@
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;
}
}
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record BulkDeleteChannels(IReadOnlyList<int> ChannelIds) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,62 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class BulkDeleteChannelsHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IFileSystem fileSystem,
ISearchTargets searchTargets)
: IRequestHandler<BulkDeleteChannels, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
BulkDeleteChannels request,
CancellationToken cancellationToken)
{
if (request.ChannelIds.Count == 0)
{
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
List<int> channelIds = request.ChannelIds.Distinct().ToList();
List<Channel> channels = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channels.Count != channelIds.Count)
{
var found = channels.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
}
dbContext.Channels.RemoveRange(channels);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
foreach (Channel channel in channels)
{
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (fileSystem.File.Exists(cacheFile))
{
fileSystem.File.Delete(cacheFile);
}
}
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Right<BaseError, Unit>(Unit.Default);
}
}
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record BulkMoveChannelsToGroup(IReadOnlyList<int> ChannelIds, string Group)
: IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,61 @@
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.ChannelValidations;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class BulkMoveChannelsToGroupHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ISearchTargets searchTargets)
: IRequestHandler<BulkMoveChannelsToGroup, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
BulkMoveChannelsToGroup request,
CancellationToken cancellationToken)
{
if (request.ChannelIds.Count == 0)
{
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
}
Validation<BaseError, string> groupValidation = ValidateGroup(request.Group);
if (groupValidation.IsFail)
{
return Left<BaseError, Unit>(groupValidation.FailToSeq().Head());
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
List<int> channelIds = request.ChannelIds.Distinct().ToList();
List<Channel> channels = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channels.Count != channelIds.Count)
{
var found = channels.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
}
foreach (Channel channel in channels)
{
channel.Group = request.Group;
}
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Right<BaseError, Unit>(Unit.Default);
}
}
@@ -129,7 +129,89 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
.IncludeGuideMetadata()
.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)
.AsSplitQuery()
.ToListAsync(cancellationToken);
@@ -162,9 +244,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
item.Finish += playoutOffset;
}
await WriteScheduleXml(
await WritePlayoutXml(
request,
playout.ScheduleKind,
floodSorted,
templateContext,
movieTemplate,
@@ -189,9 +270,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
item.Finish += playoutOffset;
}
await WriteScheduleXml(
await WriteBlockPlayoutXml(
request,
playout.ScheduleKind,
blockSorted,
templateContext,
movieTemplate,
@@ -214,9 +294,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
item.Finish += playoutOffset;
}
await WriteScheduleXml(
await WritePlayoutXml(
request,
playout.ScheduleKind,
externalJsonSorted,
templateContext,
movieTemplate,
@@ -245,9 +324,100 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
}
}
private async Task WriteScheduleXml(
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(
RefreshChannelData request,
PlayoutScheduleKind scheduleKind,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
@@ -268,36 +438,98 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
// 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 groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
{
string start = entry.Start
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
if (itemsToInclude.Count == 0)
{
continue;
}
string stop = entry.Stop
.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()
};
await WriteItemToXml(
request,
entry.DisplayItem,
start,
stop,
entry.HasCustomTitle,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
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;
}
}
}
@@ -317,8 +549,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
XmlMinifier minifier,
XmlWriter xml)
{
string title = ChannelGuideMetadata.GetTitle(displayItem);
string subtitle = ChannelGuideMetadata.GetSubtitle(displayItem);
string title = GetTitle(displayItem);
string subtitle = GetSubtitle(displayItem);
Option<string> maybeTemplateOutput = displayItem.MediaItem switch
{
@@ -885,6 +1117,51 @@ 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()
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
@@ -14,18 +16,41 @@ public class UpdateChannelNumbersHandler(
{
public async Task<Option<BaseError>> Handle(UpdateChannelNumbers request, CancellationToken cancellationToken)
{
Option<BaseError> validationError = ValidateRequest(request);
if (validationError.IsSome)
{
return validationError;
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
var channelIds = numberUpdates.Keys;
List<int> channelIds = numberUpdates.Keys.ToList();
List<Channel> channelsToUpdate = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channelsToUpdate.Count != channelIds.Count)
{
var found = channelsToUpdate.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return new NotFoundError($"Channel {missingId} does not exist.");
}
List<string> requestedNumbers = numberUpdates.Values.ToList();
bool numberConflict = await dbContext.Channels
.AnyAsync(
c => requestedNumbers.Contains(c.Number) && !channelIds.Contains(c.Id),
cancellationToken);
if (numberConflict)
{
return BaseError.New("Channel number must be unique");
}
// give every channel a non-conflicting number
foreach (var channel in channelsToUpdate)
{
@@ -69,4 +94,32 @@ public class UpdateChannelNumbersHandler(
return BaseError.New("Failed to update channel numbers: " + ex.Message);
}
}
private static Option<BaseError> ValidateRequest(UpdateChannelNumbers request)
{
if (request.Channels.Count == 0)
{
return BaseError.New("At least one channel is required");
}
if (request.Channels.Select(c => c.Id).Distinct().Count() != request.Channels.Count)
{
return BaseError.New("Channel ids must be unique");
}
if (request.Channels.Select(c => c.Number).Distinct(StringComparer.Ordinal).Count() != request.Channels.Count)
{
return BaseError.New("Channel number must be unique");
}
foreach (ChannelSortViewModel channel in request.Channels)
{
if (!Regex.IsMatch(channel.Number, Channel.NumberValidator))
{
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
}
}
return Option<BaseError>.None;
}
}
+6 -1
View File
@@ -42,10 +42,15 @@ internal static class Mapper
new(
channel.Id,
channel.Number,
channel.SortNumber,
channel.Name,
channel.Group,
channel.Categories,
channel.FFmpegProfile.Name,
channel.PreferredAudioLanguageCode,
GetStreamingMode(channel));
GetStreamingMode(channel),
channel.IsEnabled,
channel.ShowInEpg);
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
@@ -1,10 +0,0 @@
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>;
@@ -1,145 +0,0 @@
using System.Globalization;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Builds the JSON channel guide directly from <see cref="Playout" /> items, using the shared
/// <see cref="ChannelGuideProjector" /> guide-group/filler-merge logic (the same logic the XMLTV
/// cache builder uses) so the two representations cannot drift. Only channels with
/// <see cref="Channel.ShowInEpg" /> are included, mirroring <c>GetChannelGuideHandler</c>.
/// Unlike XMLTV, filler programmes are returned (with their <see cref="Core.Domain.Filler.FillerKind" />)
/// so the UI can decide how to render them.
/// </summary>
public class GetChannelGuideDataHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelGuideData, ChannelGuideResponseModel>
{
public async Task<ChannelGuideResponseModel> Handle(
GetChannelGuideData request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int daysToBuild = await configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
.IfNoneAsync(2);
XmltvTimeZone xmltvTimeZone = await configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
XmltvBlockBehavior xmltvBlockBehavior = await configElementRepository
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
DateTimeOffset start = request.Start ?? DateTimeOffset.UtcNow;
DateTimeOffset end = request.End ?? start.AddDays(daysToBuild);
// Visible channels only (mirror GetChannelGuideHandler's ShowInEpg == false skip).
List<Channel> channels = await dbContext.Channels
.AsNoTracking()
.Where(c => c.ShowInEpg)
.Include(c => c.MirrorSourceChannel)
.ToListAsync(cancellationToken);
// Order channels by their decimal channel number so "2" precedes "10", matching
// ChannelGuide.ToXml (which orders XMLTV channels by decimal.Parse of the number).
channels = channels
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture))
.ToList();
var responseChannels = new List<ChannelGuideChannelResponseModel>();
foreach (Channel channel in channels)
{
bool isMirror = channel.PlayoutSource == ChannelPlayoutSource.Mirror
&& channel.MirrorSourceChannel is not null;
string sourceChannelNumber = isMirror ? channel.MirrorSourceChannel.Number : channel.Number;
TimeSpan playoutOffset = isMirror ? channel.PlayoutOffset ?? TimeSpan.Zero : TimeSpan.Zero;
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(p => p.Channel.Number == sourceChannelNumber)
.IncludeGuideMetadata()
.AsSplitQuery()
.ToListAsync(cancellationToken);
var programmes = new List<ChannelGuideProgrammeResponseModel>();
foreach (Playout playout in playouts)
{
// ExternalJson playouts materialize items from a file rather than Playout.Items; they are
// out of scope for the JSON guide (see issue #102 notes).
if (playout.ScheduleKind is PlayoutScheduleKind.ExternalJson)
{
continue;
}
// Filter to the window (on the pre-offset time, mirroring the XMLTV builder) then apply the
// mirror playout offset without mutating the loaded (shared, AsNoTracking) entities.
List<PlayoutItem> sorted = playout.Items
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= end)
.Select(pi => playoutOffset == TimeSpan.Zero ? pi : WithPlayoutOffset(pi, playoutOffset))
.ToList();
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
playout.ScheduleKind,
sorted,
xmltvTimeZone,
xmltvBlockBehavior))
{
// drop programmes that finish before the requested window starts
if (entry.Stop <= start)
{
continue;
}
string subtitle = ChannelGuideMetadata.GetSubtitle(entry.DisplayItem);
programmes.Add(
new ChannelGuideProgrammeResponseModel(
entry.Start,
entry.Stop,
ChannelGuideMetadata.GetTitle(entry.DisplayItem),
string.IsNullOrWhiteSpace(subtitle) ? null : subtitle,
ChannelGuideMetadata.GetCategory(entry.DisplayItem),
entry.DisplayItem.FillerKind));
}
}
responseChannels.Add(
new ChannelGuideChannelResponseModel(
channel.Number,
channel.Name,
programmes.OrderBy(p => p.Start).ToList()));
}
return new ChannelGuideResponseModel(start, end, responseChannels);
}
// Copy (don't mutate) the loaded PlayoutItem when shifting by the mirror playout offset. The loaded
// entities are AsNoTracking and shared; mutating them in place would corrupt the guide projection.
// Mirrors the XMLTV builder, which shifts only Start/Finish (not the Guide* window).
private static PlayoutItem WithPlayoutOffset(PlayoutItem item, TimeSpan offset) =>
new()
{
MediaItem = item.MediaItem,
Start = item.Start + offset,
Finish = item.Finish + offset,
GuideStart = item.GuideStart,
GuideFinish = item.GuideFinish,
GuideGroup = item.GuideGroup,
FillerKind = item.FillerKind,
CustomTitle = item.CustomTitle
};
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetChannelStatesForApi(DateTime Now) : IRequest<List<ChannelStateResponseModel>>;
@@ -0,0 +1,151 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelStatesForApiHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegSegmenterService ffmpegSegmenterService)
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
{
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
// bound also protects against GuideGroup values recycling (mod 10000) elsewhere in a playout
private static readonly TimeSpan GuideEntryBound = TimeSpan.FromDays(1);
public async Task<List<ChannelStateResponseModel>> Handle(
GetChannelStatesForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Channel> channels = await dbContext.Channels
.AsNoTracking()
.OrderBy(c => c.SortNumber)
.ThenBy(c => c.Number)
.ToListAsync(cancellationToken);
if (channels.Count == 0)
{
return [];
}
Dictionary<int, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> channelLookup = channels
.ToDictionary(
c => c.Id,
c =>
{
TimeSpan offset = c.PlayoutOffset ?? TimeSpan.Zero;
return (c.MirrorSourceChannelId ?? c.Id, offset, request.Now - offset);
});
// one covering-item query per distinct lookup time (i.e. per distinct playout offset,
// typically just one) with a cheap projection; metadata is hydrated in a second query
// for only the covering items' guide groups
var coveringBySourceAndTime = new Dictionary<(int SourceChannelId, DateTime LookupTime), CoveringItem>();
foreach (IGrouping<DateTime, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> timeGroup in
channelLookup.Values.GroupBy(v => v.LookupTime))
{
DateTime lookupTime = timeGroup.Key;
int[] sourceChannelIds = timeGroup.Map(v => v.SourceChannelId).Distinct().ToArray();
List<CoveringItem> covering = await dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => sourceChannelIds.Contains(pi.Playout.ChannelId))
.Where(pi => pi.Start <= lookupTime && pi.Finish > lookupTime)
.Select(pi => new CoveringItem(pi.Id, pi.PlayoutId, pi.Playout.ChannelId, pi.GuideGroup, pi.Start))
.ToListAsync(cancellationToken);
foreach (CoveringItem item in covering.OrderBy(ci => ci.Start))
{
coveringBySourceAndTime.TryAdd((item.ChannelId, lookupTime), item);
}
}
Dictionary<(int PlayoutId, int GuideGroup), List<PlayoutItem>> itemsByGuideEntry = [];
if (coveringBySourceAndTime.Count > 0)
{
int[] playoutIds = coveringBySourceAndTime.Values.Map(ci => ci.PlayoutId).Distinct().ToArray();
int[] guideGroups = coveringBySourceAndTime.Values.Map(ci => ci.GuideGroup).Distinct().ToArray();
DateTime windowStart = channelLookup.Values.Min(v => v.LookupTime) - GuideEntryBound;
DateTime windowFinish = channelLookup.Values.Max(v => v.LookupTime) + GuideEntryBound;
List<PlayoutItem> guideEntryItems = await dbContext.PlayoutItems
.AsNoTracking()
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Movie).MovieMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Song).SongMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Image).ImageMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
.Where(pi => playoutIds.Contains(pi.PlayoutId) && guideGroups.Contains(pi.GuideGroup))
.Where(pi => pi.Start <= windowFinish && pi.Finish > windowStart)
.ToListAsync(cancellationToken);
itemsByGuideEntry = guideEntryItems
.GroupBy(pi => (pi.PlayoutId, pi.GuideGroup))
.ToDictionary(g => g.Key, g => g.OrderBy(pi => pi.Start).ToList());
}
return channels
.Map(channel =>
{
(int sourceChannelId, TimeSpan offset, DateTime lookupTime) = channelLookup[channel.Id];
ChannelNowPlayingResponseModel nowPlaying = null;
if (coveringBySourceAndTime.TryGetValue((sourceChannelId, lookupTime), out CoveringItem covering) &&
itemsByGuideEntry.TryGetValue(
(covering.PlayoutId, covering.GuideGroup),
out List<PlayoutItem> guideEntry))
{
// like the XMLTV guide, surface the program rather than its filler: use the
// covering item itself when it is a program part, otherwise the guide entry's
// first program item; an entry with no program item (e.g. offline fallback
// filler) stays null
PlayoutItem coveringItem = guideEntry.Find(pi => pi.Id == covering.Id);
PlayoutItem displayItem = coveringItem?.FillerKind == FillerKind.None
? coveringItem
: guideEntry.Find(pi => pi.FillerKind == FillerKind.None);
if (displayItem is not null)
{
DateTime start = guideEntry[0].Start;
DateTime finish = displayItem.GuideFinish ?? guideEntry.Max(pi => pi.Finish);
nowPlaying = new ChannelNowPlayingResponseModel(
PlayoutMapper.GetDisplayTitle(displayItem.MediaItem, Optional(displayItem.ChapterTitle)),
new DateTimeOffset(start + offset, TimeSpan.Zero),
new DateTimeOffset(finish + offset, TimeSpan.Zero));
}
}
return new ChannelStateResponseModel(
channel.Id,
channel.Number,
ffmpegSegmenterService.IsActive(channel.Number),
nowPlaying);
})
.ToList();
}
private sealed record CoveringItem(int Id, int PlayoutId, int ChannelId, int GuideGroup, DateTime Start);
}
+1 -5
View File
@@ -1,13 +1,9 @@
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.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,
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.Filler;
namespace ErsatzTV.Application.Filler;
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
@@ -1,22 +0,0 @@
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();
}
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.Graphics;
namespace ErsatzTV.Application.Graphics;
public record GetAllGraphicsElementsForApi : IRequest<List<GraphicsElementResponseModel>>;
@@ -1,27 +0,0 @@
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();
}
}
-24
View File
@@ -1,24 +0,0 @@
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)
};
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.Health;
namespace ErsatzTV.Application.Health;
public record GetAllHealthCheckResultsForApi : IRequest<List<HealthCheckResponseModel>>;
@@ -1,32 +0,0 @@
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 [];
}
}
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.Libraries;
namespace ErsatzTV.Application.Libraries;
public record GetLibraryScanStatus : IRequest<List<LibraryScanStatusResponseModel>>;
@@ -1,20 +0,0 @@
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);
}
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.MediaSources;
namespace ErsatzTV.Application.MediaSources;
public record GetAllMediaSourcesForApi : IRequest<List<MediaSourceResponseModel>>;
@@ -1,148 +0,0 @@
#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,7 +16,6 @@ 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,33 +44,6 @@ 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,
@@ -1,18 +0,0 @@
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);
@@ -1,4 +0,0 @@
namespace ErsatzTV.Application.ProgramSchedules;
public record GetProgramScheduleItemsWithDurations(int Id)
: IRequest<ProgramScheduleItemsWithDurationViewModel>;
@@ -1,70 +0,0 @@
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;
}
}
@@ -1,73 +0,0 @@
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,14 +1,10 @@
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,
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.Watermarks;
namespace ErsatzTV.Application.Watermarks;
public record GetAllWatermarksForApi : IRequest<List<WatermarkResponseModel>>;
@@ -1,22 +0,0 @@
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();
}
}
@@ -1,10 +0,0 @@
#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);
@@ -1,25 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Channels;
/// <summary>A single guide programme for the JSON EPG grid.</summary>
public record ChannelGuideProgrammeResponseModel(
DateTimeOffset Start,
DateTimeOffset Stop,
string Title,
string? SubTitle,
string? Category,
FillerKind FillerKind);
/// <summary>One channel's guide programmes for the requested window.</summary>
public record ChannelGuideChannelResponseModel(
string Number,
string Name,
List<ChannelGuideProgrammeResponseModel> Programmes);
/// <summary>The JSON channel-guide response: the resolved window plus per-channel programme arrays.</summary>
public record ChannelGuideResponseModel(
DateTimeOffset Start,
DateTimeOffset End,
List<ChannelGuideChannelResponseModel> Channels);
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.Channels;
#nullable enable
public record ChannelNowPlayingResponseModel(
string Title,
DateTimeOffset StartUtc,
DateTimeOffset FinishUtc);
@@ -5,8 +5,13 @@ namespace ErsatzTV.Core.Api.Channels;
public record ChannelResponseModel(
int Id,
string Number,
double SortNumber,
string Name,
string Group,
string Categories,
[property: JsonProperty("ffmpegProfile")]
string FFmpegProfile,
string Language,
string StreamingMode);
string StreamingMode,
bool IsEnabled,
bool ShowInEpg);
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Api.Channels;
#nullable enable
public record ChannelStateResponseModel(
int ChannelId,
string ChannelNumber,
bool OnAir,
ChannelNowPlayingResponseModel? NowPlaying);
@@ -1,3 +0,0 @@
namespace ErsatzTV.Core.Api.Filler;
public record FillerPresetResponseModel(int Id, string Name);
@@ -1,3 +0,0 @@
namespace ErsatzTV.Core.Api.Graphics;
public record GraphicsElementResponseModel(int Id, string Name);
@@ -1,8 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Health;
public record HealthCheckResponseModel(
string Title,
string Status,
string Detail,
string? Link);
@@ -1,3 +0,0 @@
namespace ErsatzTV.Core.Api.Libraries;
public record LibraryScanStatusResponseModel(int LibraryId, decimal Percent);
@@ -1,11 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaSources;
public record MediaSourceLibraryResponseModel(
int Id,
string Name,
LibraryMediaKind MediaKind,
DateTime? LastScan,
int ItemCount);
@@ -1,9 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record MediaSourceResponseModel(
int Id,
string Kind,
string Name,
string? ConnectionAddress,
List<MediaSourceLibraryResponseModel> Libraries);
@@ -1,5 +0,0 @@
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutItemsResponseModel(
int TotalCount,
List<PlayoutItemResponseModel> Page);
@@ -1,5 +0,0 @@
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutsResponseModel(
int TotalCount,
List<PlayoutListItemResponseModel> Page);
@@ -1,6 +0,0 @@
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutBuildStatusResponseModel(
DateTimeOffset LastBuild,
bool Success,
string Message);
@@ -1,10 +0,0 @@
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutItemResponseModel(
string Title,
DateTimeOffset Start,
DateTimeOffset Finish,
string Duration,
FillerKind? FillerKind);
@@ -1,13 +0,0 @@
#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,4 +1,3 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Playouts;
@@ -10,9 +9,8 @@ public record PlayoutResponseModel(
string ChannelNumber,
ChannelPlayoutMode PlayoutMode,
string ScheduleName,
string? ScheduleFile,
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus)
string ScheduleFile,
TimeSpan? DailyRebuildTime)
{
public static PlayoutResponseModel From(
int id,
@@ -21,9 +19,8 @@ public record PlayoutResponseModel(
string channelNumber,
ChannelPlayoutMode playoutMode,
string scheduleName,
string? scheduleFile,
TimeSpan? dailyRebuildTime,
PlayoutBuildStatusResponseModel? buildStatus) =>
string scheduleFile,
TimeSpan? dailyRebuildTime) =>
new(
id,
scheduleKind,
@@ -32,6 +29,5 @@ public record PlayoutResponseModel(
playoutMode,
scheduleName,
scheduleFile,
dailyRebuildTime,
buildStatus);
dailyRebuildTime);
}
@@ -1,3 +0,0 @@
namespace ErsatzTV.Core.Api.Watermarks;
public record WatermarkResponseModel(int Id, string Name);
@@ -1,5 +1,3 @@
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Metadata;
public interface IScannerProxyService
@@ -9,5 +7,4 @@ public interface IScannerProxyService
Task Progress(Guid scanId, decimal percentComplete);
bool IsActive(Guid scanId);
Option<decimal> GetProgress(int libraryId);
IReadOnlyList<LibraryScanProgress> GetActiveScans();
}
@@ -49,7 +49,4 @@ 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();
}
@@ -1,98 +0,0 @@
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);
}
@@ -1,87 +0,0 @@
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,57 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class BulkDeleteChannelsHandlerTests : ChannelHandlerTestBase
{
private readonly MockFileSystem _fileSystem = new();
private BulkDeleteChannelsHandler MakeHandler() => new(Db.Factory, Worker, _fileSystem, SearchTargets);
[Test]
public async Task Should_Delete_All_Channels()
{
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkDeleteChannels([1, 2]), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
int count = await context.Channels.CountAsync();
count.ShouldBe(0);
SearchTargets.Received(1).SearchTargetsChanged();
}
[Test]
public async Task Should_Return_NotFound_And_Not_Delete_When_Any_Channel_Is_Missing()
{
await SeedChannel(1, "5");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkDeleteChannels([1, 99]), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldBeOfType<NotFoundError>();
await using TvContext context = Db.CreateContext();
bool exists = await context.Channels.AnyAsync(c => c.Id == 1);
exists.ShouldBeTrue();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -0,0 +1,74 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class BulkMoveChannelsToGroupHandlerTests : ChannelHandlerTestBase
{
private BulkMoveChannelsToGroupHandler MakeHandler() => new(Db.Factory, Worker, SearchTargets);
[Test]
public async Task Should_Move_All_Channels_To_Group()
{
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 2], "Movies"), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
List<string> groups = await context.Channels
.OrderBy(c => c.Id)
.Select(c => c.Group)
.ToListAsync();
groups.ShouldBe(["Movies", "Movies"]);
SearchTargets.Received(1).SearchTargetsChanged();
}
[Test]
public async Task Should_Return_NotFound_And_Not_Move_When_Any_Channel_Is_Missing()
{
await SeedChannel(1, "5", group: "Original");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 99], "Movies"), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldBeOfType<NotFoundError>();
await using TvContext context = Db.CreateContext();
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
group.ShouldBe("Original");
}
[Test]
public async Task Should_Reject_Empty_Group()
{
await SeedChannel(1, "5", group: "Original");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1], ""), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("group");
await using TvContext context = Db.CreateContext();
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
group.ShouldBe("Original");
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -0,0 +1,53 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetAllChannelsForApiHandlerTests
{
[Test]
public async Task Should_Project_Management_Table_Fields()
{
IChannelRepository repository = Substitute.For<IChannelRepository>();
repository.GetAll(Arg.Any<CancellationToken>())
.Returns([
new Channel(Guid.NewGuid())
{
Id = 7,
Number = "7.1",
SortNumber = 7.1,
Name = "Retro Cartoons",
Group = "Kids",
Categories = "animation",
FFmpegProfile = new FFmpegProfile { Name = "HLS 720p" },
PreferredAudioLanguageCode = "eng",
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
IsEnabled = false,
ShowInEpg = false
}
]);
var handler = new GetAllChannelsForApiHandler(repository);
List<ChannelResponseModel> result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None);
ChannelResponseModel channel = result.ShouldHaveSingleItem();
channel.Id.ShouldBe(7);
channel.Number.ShouldBe("7.1");
channel.SortNumber.ShouldBe(7.1);
channel.Name.ShouldBe("Retro Cartoons");
channel.Group.ShouldBe("Kids");
channel.Categories.ShouldBe("animation");
channel.FFmpegProfile.ShouldBe("HLS 720p");
channel.Language.ShouldBe("eng");
channel.StreamingMode.ShouldBe("HLS Segmenter");
channel.IsEnabled.ShouldBeFalse();
channel.ShowInEpg.ShouldBeFalse();
}
}
@@ -1,316 +0,0 @@
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,329 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetChannelStatesForApiHandlerTests
{
private static readonly DateTime Now = new(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc);
private InMemoryTvContext _db = null!;
private IFFmpegSegmenterService _segmenter = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_segmenter = Substitute.For<IFFmpegSegmenterService>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Return_OnAir_And_Current_NowPlaying()
{
DateTime start = Now.AddMinutes(-10);
DateTime finish = Now.AddMinutes(20);
await SeedChannelWithMovie(start, finish);
_segmenter.IsActive("7.1").Returns(true);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
state.ChannelId.ShouldBe(7);
state.ChannelNumber.ShouldBe("7.1");
state.OnAir.ShouldBeTrue();
state.NowPlaying.ShouldNotBeNull();
state.NowPlaying.Title.ShouldBe("Retro Cartoons");
state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(start, TimeSpan.Zero));
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
}
[Test]
public async Task Handle_Should_Match_Item_When_Now_Equals_Start()
{
await SeedChannelWithMovie(Now, Now.AddMinutes(30));
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
result.ShouldHaveSingleItem().NowPlaying.ShouldNotBeNull();
}
[Test]
public async Task Handle_Should_Not_Match_Item_When_Now_Equals_Finish()
{
await SeedChannelWithMovie(Now.AddMinutes(-30), Now);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Return_Null_NowPlaying_When_No_Current_Item()
{
await using TvContext context = _db.CreateContext();
context.Channels.Add(MakeChannel(8, "8"));
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
state.OnAir.ShouldBeFalse();
state.NowPlaying.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Surface_Program_Not_Filler_During_MidRoll_Break()
{
// program part 1 / mid-roll filler / program part 2, all in one guide group
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(12, "12");
var movie = new Movie
{
Id = 120,
MovieMetadata = [new MovieMetadata { Title = "Feature Presentation" }]
};
var filler = new OtherVideo
{
Id = 121,
OtherVideoMetadata = [new OtherVideoMetadata { Title = "Some Bumper" }]
};
var playout = new Playout { Id = 122, Channel = channel, ChannelId = channel.Id, Items = [] };
PlayoutItem MakeItem(int id, MediaItem mediaItem, DateTime start, DateTime finish, FillerKind fillerKind) =>
new()
{
Id = id,
MediaItem = mediaItem,
MediaItemId = mediaItem.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = start,
Finish = finish,
FillerKind = fillerKind,
GuideGroup = 1,
ChapterTitle = string.Empty
};
context.Channels.Add(channel);
context.Movies.Add(movie);
context.OtherVideos.Add(filler);
context.Playouts.Add(playout);
context.PlayoutItems.AddRange(
MakeItem(123, movie, Now.AddMinutes(-20), Now.AddMinutes(-2), FillerKind.None),
MakeItem(124, filler, Now.AddMinutes(-2), Now.AddMinutes(2), FillerKind.MidRoll),
MakeItem(125, movie, Now.AddMinutes(2), Now.AddMinutes(40), FillerKind.None));
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
state.NowPlaying.ShouldNotBeNull();
state.NowPlaying.Title.ShouldBe("Feature Presentation");
state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(-20), TimeSpan.Zero));
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(40), TimeSpan.Zero));
}
[Test]
public async Task Handle_Should_Return_Null_NowPlaying_For_Guide_Entry_With_Only_Filler()
{
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(13, "13");
var filler = new OtherVideo
{
Id = 130,
OtherVideoMetadata = [new OtherVideoMetadata { Title = "Offline Loop" }]
};
var playout = new Playout { Id = 131, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = 132,
MediaItem = filler,
MediaItemId = filler.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.AddMinutes(-5),
Finish = Now.AddMinutes(5),
FillerKind = FillerKind.Fallback,
GuideGroup = 1,
ChapterTitle = string.Empty
};
context.Channels.Add(channel);
context.OtherVideos.Add(filler);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Map_Remote_Stream_NowPlaying_Title()
{
DateTime start = Now.AddMinutes(-10);
DateTime finish = Now.AddMinutes(20);
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(9, "9");
var remoteStream = new RemoteStream
{
Id = 90,
RemoteStreamMetadata = [new RemoteStreamMetadata { Title = "Live Source" }]
};
var playout = new Playout { Id = 91, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = 92,
MediaItem = remoteStream,
MediaItemId = remoteStream.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = start,
Finish = finish,
ChapterTitle = string.Empty
};
context.Channels.Add(channel);
context.RemoteStreams.Add(remoteStream);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
ChannelStateResponseModel state =
(await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None))
.ShouldHaveSingleItem();
state.NowPlaying.ShouldNotBeNull();
state.NowPlaying.Title.ShouldBe("Live Source");
}
[Test]
public async Task Handle_Should_Resolve_Mirror_Channel_NowPlaying_From_Source_With_Offset()
{
TimeSpan offset = TimeSpan.FromHours(1);
DateTime sourceStart = Now.AddMinutes(-70);
DateTime sourceFinish = Now.AddMinutes(-40);
await using TvContext context = _db.CreateContext();
Channel source = MakeChannel(10, "10");
Channel mirror = MakeChannel(11, "11");
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
mirror.MirrorSourceChannelId = source.Id;
mirror.PlayoutOffset = offset;
var movie = new Movie
{
Id = 110,
MovieMetadata = [new MovieMetadata { Title = "Offset Feature" }]
};
var playout = new Playout { Id = 111, Channel = source, ChannelId = source.Id, Items = [] };
var item = new PlayoutItem
{
Id = 112,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = sourceStart,
Finish = sourceFinish,
ChapterTitle = string.Empty
};
context.Channels.AddRange(source, mirror);
context.Movies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
ChannelStateResponseModel mirrorState = result.Single(s => s.ChannelId == mirror.Id);
mirrorState.NowPlaying.ShouldNotBeNull();
mirrorState.NowPlaying.Title.ShouldBe("Offset Feature");
mirrorState.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(sourceStart + offset, TimeSpan.Zero));
mirrorState.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
// the source's own item is 40+ minutes in the past with no offset, so its own row is off-air
ChannelStateResponseModel sourceState = result.Single(s => s.ChannelId == source.Id);
sourceState.NowPlaying.ShouldBeNull();
}
private async Task SeedChannelWithMovie(DateTime start, DateTime finish)
{
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(7, "7.1");
var movie = new Movie
{
Id = 10,
MovieMetadata = [new MovieMetadata { Title = "Retro Cartoons" }]
};
var playout = new Playout
{
Id = 20,
Channel = channel,
ChannelId = channel.Id,
Items = []
};
var item = new PlayoutItem
{
Id = 30,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = start,
Finish = finish,
ChapterTitle = string.Empty
};
context.Channels.Add(channel);
context.Movies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
}
private static Channel MakeChannel(int id, string number) =>
new(Guid.NewGuid())
{
Id = id,
Number = number,
SortNumber = id,
Name = $"Channel {number}",
Group = "Test",
Categories = string.Empty,
PreferredAudioLanguageCode = string.Empty
};
}
@@ -0,0 +1,111 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class UpdateChannelNumbersHandlerTests : ChannelHandlerTestBase
{
private UpdateChannelNumbersHandler MakeHandler() => new(Db.Factory, Worker);
[Test]
public async Task Should_Renumber_All_Channels()
{
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Option<BaseError> result = await MakeHandler().Handle(
new UpdateChannelNumbers(
[
new ChannelSortViewModel { Id = 1, Number = "10" },
new ChannelSortViewModel { Id = 2, Number = "11.1" }
]),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
var channels = await context.Channels
.OrderBy(c => c.Id)
.Select(c => new { c.Number, c.SortNumber })
.ToListAsync();
channels[0].Number.ShouldBe("10");
channels[0].SortNumber.ShouldBe(10);
channels[1].Number.ShouldBe("11.1");
channels[1].SortNumber.ShouldBe(11.1);
}
[Test]
public async Task Should_Return_NotFound_And_Not_Renumber_When_Any_Channel_Is_Missing()
{
await SeedChannel(1, "5");
Option<BaseError> result = await MakeHandler().Handle(
new UpdateChannelNumbers(
[
new ChannelSortViewModel { Id = 1, Number = "10" },
new ChannelSortViewModel { Id = 99, Number = "11" }
]),
CancellationToken.None);
BaseError error = ErrorOf(result);
error.ShouldBeOfType<NotFoundError>();
await using TvContext context = Db.CreateContext();
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
number.ShouldBe("5");
}
[Test]
public async Task Should_Reject_Duplicate_Numbers_And_Not_Renumber()
{
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Option<BaseError> result = await MakeHandler().Handle(
new UpdateChannelNumbers(
[
new ChannelSortViewModel { Id = 1, Number = "10" },
new ChannelSortViewModel { Id = 2, Number = "10" }
]),
CancellationToken.None);
BaseError error = ErrorOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("unique");
await using TvContext context = Db.CreateContext();
List<string> numbers = await context.Channels.OrderBy(c => c.Id).Select(c => c.Number).ToListAsync();
numbers.ShouldBe(["5", "6"]);
}
[Test]
public async Task Should_Reject_Invalid_Number_And_Not_Renumber()
{
await SeedChannel(1, "5");
Option<BaseError> result = await MakeHandler().Handle(
new UpdateChannelNumbers([new ChannelSortViewModel { Id = 1, Number = "10.123" }]),
CancellationToken.None);
BaseError error = ErrorOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("Invalid channel number");
await using TvContext context = Db.CreateContext();
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
number.ShouldBe("5");
}
private static BaseError ErrorOf(Option<BaseError> result) =>
result.Match(
Some: error => error,
None: () => throw new AssertionException("Expected an error"));
}
@@ -1,63 +0,0 @@
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();
}
}
@@ -1,77 +0,0 @@
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();
}
}
@@ -1,111 +0,0 @@
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();
}
}
@@ -1,32 +0,0 @@
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)]);
}
}
@@ -1,179 +0,0 @@
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()
};
}
@@ -1,247 +0,0 @@
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 }]
};
}
@@ -1,71 +0,0 @@
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();
}
}
@@ -17,6 +17,12 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
[TestCase(typeof(CollectionController), nameof(CollectionController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status404NotFound)]
@@ -1,164 +0,0 @@
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,7 +9,6 @@ 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;
@@ -25,15 +24,34 @@ 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>();
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
_controller = new ChannelController(_workerChannel.Writer, _mediator);
ChannelWriter<IBackgroundServiceRequest> writer =
System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
_controller = new ChannelController(writer, _mediator);
}
[Test]
public async Task GetState_Should_Return_200_With_Channel_State()
{
var state = new ChannelStateResponseModel(
7,
"7.1",
true,
new ChannelNowPlayingResponseModel(
"Retro Cartoons",
new DateTimeOffset(2026, 7, 2, 17, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 17, 30, 0, TimeSpan.Zero)));
_mediator.Send(Arg.Any<GetChannelStatesForApi>(), Arg.Any<CancellationToken>())
.Returns([state]);
List<ChannelStateResponseModel> result = await _controller.GetState(CancellationToken.None);
result.ShouldBe(new List<ChannelStateResponseModel> { state });
}
[Test]
@@ -128,34 +146,94 @@ public class ChannelControllerTests
}
[Test]
public async Task GetGuide_Should_Map_Query_And_Return_Model()
public async Task BulkRenumber_Should_Return_204_And_Map_Request()
{
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);
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
ChannelGuideResponseModel result = await _controller.GetGuide(start, end, CancellationToken.None);
IActionResult result = await _controller.BulkRenumber(
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "10")]),
CancellationToken.None);
result.ShouldBe(model);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<GetChannelGuideData>(q => q.Start == start && q.End == end),
Arg.Is<UpdateChannelNumbers>(
c => c.Channels.Count == 1 && c.Channels[0].Id == 1 && c.Channels[0].Number == "10"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetGuide_Should_Pass_Null_Bounds_Through()
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
{
var model = new ChannelGuideResponseModel(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, []);
_mediator.Send(Arg.Any<GetChannelGuideData>(), Arg.Any<CancellationToken>()).Returns(model);
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(BaseError.New("bad")));
await _controller.GetGuide(null, null, CancellationToken.None);
IActionResult result = await _controller.BulkRenumber(
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "bad")]),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task BulkMoveToGroup_Should_Return_204_And_Map_Request()
{
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.BulkMoveToGroup(
new BulkMoveChannelsToGroupRequest([1, 2], "Movies"),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<GetChannelGuideData>(q => q.Start == null && q.End == null),
Arg.Is<BulkMoveChannelsToGroup>(
c => c.ChannelIds.SequenceEqual(new[] { 1, 2 }) && c.Group == "Movies"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task BulkMoveToGroup_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.BulkMoveToGroup(
new BulkMoveChannelsToGroupRequest([99], "Movies"),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task BulkDelete_Should_Return_204_And_Map_Request()
{
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.BulkDelete(
new BulkDeleteChannelsRequest([1, 2]),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<BulkDeleteChannels>(c => c.ChannelIds.SequenceEqual(new[] { 1, 2 })),
Arg.Any<CancellationToken>());
}
[Test]
public async Task BulkDelete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.BulkDelete(
new BulkDeleteChannelsRequest([99]),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
@@ -188,7 +266,7 @@ public class ChannelControllerTests
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.None);
IActionResult result = await _controller.ResetPlayout("404", mode: null, CancellationToken.None);
IActionResult result = await _controller.ResetPlayout("404");
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
@@ -196,53 +274,6 @@ 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,
@@ -1,63 +0,0 @@
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();
}
}
@@ -1,63 +0,0 @@
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();
}
}
@@ -1,65 +0,0 @@
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();
}
}
@@ -1,55 +0,0 @@
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);
}
}
@@ -1,59 +0,0 @@
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);
}
}
@@ -23,6 +23,82 @@ public class OpenApiErrorResponseContractTests
.ShouldContain("One");
}
[Test]
public void Static_OpenApi_Should_Document_Channel_List_Management_Fields()
{
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
JsonElement schema = document.RootElement
.GetProperty("components")
.GetProperty("schemas")
.GetProperty("ChannelResponseModel")
.GetProperty("properties");
schema.TryGetProperty("group", out _).ShouldBeTrue();
schema.TryGetProperty("isEnabled", out _).ShouldBeTrue();
schema.TryGetProperty("showInEpg", out _).ShouldBeTrue();
schema.TryGetProperty("sortNumber", out _).ShouldBeTrue();
schema.TryGetProperty("categories", out _).ShouldBeTrue();
}
[Test]
public void Static_OpenApi_Should_Document_Channel_State_Response()
{
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
JsonElement getState = document.RootElement
.GetProperty("paths")
.GetProperty("/api/channels/state")
.GetProperty("get");
JsonElement schema = getState
.GetProperty("responses")
.GetProperty("200")
.GetProperty("content")
.GetProperty("application/json")
.GetProperty("schema");
schema.GetProperty("type").GetString().ShouldBe("array");
schema.GetProperty("items").GetProperty("$ref").GetString()
.ShouldBe("#/components/schemas/ChannelStateResponseModel");
JsonElement stateProperties = document.RootElement
.GetProperty("components")
.GetProperty("schemas")
.GetProperty("ChannelStateResponseModel")
.GetProperty("properties");
stateProperties.TryGetProperty("channelId", out _).ShouldBeTrue();
stateProperties.TryGetProperty("channelNumber", out _).ShouldBeTrue();
stateProperties.TryGetProperty("onAir", out _).ShouldBeTrue();
stateProperties.TryGetProperty("nowPlaying", out JsonElement nowPlaying).ShouldBeTrue();
JsonElement nowPlayingOneOf = nowPlaying.GetProperty("oneOf");
nowPlayingOneOf.EnumerateArray().Count().ShouldBe(2);
nowPlayingOneOf.EnumerateArray()
.Any(s => s.TryGetProperty("type", out JsonElement type) && type.GetString() == "null")
.ShouldBeTrue();
nowPlayingOneOf.EnumerateArray()
.Any(s => s.TryGetProperty("$ref", out JsonElement schemaRef) &&
schemaRef.GetString() == "#/components/schemas/ChannelNowPlayingResponseModel")
.ShouldBeTrue();
JsonElement nowPlayingProperties = document.RootElement
.GetProperty("components")
.GetProperty("schemas")
.GetProperty("ChannelNowPlayingResponseModel")
.GetProperty("properties");
nowPlayingProperties.TryGetProperty("title", out _).ShouldBeTrue();
nowPlayingProperties.TryGetProperty("startUtc", out JsonElement startUtc).ShouldBeTrue();
nowPlayingProperties.TryGetProperty("finishUtc", out JsonElement finishUtc).ShouldBeTrue();
startUtc.GetProperty("type").GetString().ShouldBe("string");
startUtc.GetProperty("format").GetString().ShouldBe("date-time");
finishUtc.GetProperty("type").GetString().ShouldBe("string");
finishUtc.GetProperty("format").GetString().ShouldBe("date-time");
}
[TestCase("/api/channels/{id}", "get", "404")]
[TestCase("/api/channels", "post", "404")]
[TestCase("/api/channels", "post", "422")]
@@ -30,6 +106,12 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channels/{id}", "put", "422")]
[TestCase("/api/channels/{id}", "delete", "404")]
[TestCase("/api/channels/{id}", "delete", "422")]
[TestCase("/api/channels/bulk/renumber", "post", "404")]
[TestCase("/api/channels/bulk/renumber", "post", "422")]
[TestCase("/api/channels/bulk/group", "post", "404")]
[TestCase("/api/channels/bulk/group", "post", "422")]
[TestCase("/api/channels/bulk/delete", "post", "404")]
[TestCase("/api/channels/bulk/delete", "post", "422")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
[TestCase("/api/collections/{id}", "get", "404")]
[TestCase("/api/collections", "post", "404")]
@@ -68,8 +150,6 @@ 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,7 +5,6 @@ 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;
@@ -35,12 +34,8 @@ 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}");
}
@@ -167,130 +162,6 @@ 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,
@@ -312,13 +183,7 @@ public class PlayoutControllerTests
vm.PlayoutMode,
vm.ScheduleName,
vm.ScheduleFile,
vm.DbDailyRebuildTime,
vm.BuildStatus is null
? null
: new PlayoutBuildStatusResponseModel(
vm.BuildStatus.LastBuild,
vm.BuildStatus.Success,
vm.BuildStatus.Message));
vm.DbDailyRebuildTime);
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
@@ -189,18 +189,14 @@ 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<GetProgramScheduleItemsWithDurations>(), Arg.Any<CancellationToken>())
.Returns(response);
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(items);
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(response);
await _mediator.Received(1).Send(
Arg.Is<GetProgramScheduleItemsWithDurations>(q => q.Id == 4),
Arg.Any<CancellationToken>());
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(items);
}
[Test]
@@ -1,63 +0,0 @@
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();
}
}
+45
View File
@@ -0,0 +1,45 @@
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests;
[TestFixture]
public class StartupSpaHostingTests
{
private static readonly string StartupSource = File.ReadAllText(FindStartupPath());
[Test]
public void Startup_Should_Mount_ChicoryTv_Spa_At_App_Path()
{
StartupSource.ShouldContain("ctx.Request.Path.StartsWithSegments(\"/app\")");
StartupSource.ShouldContain("MapWhen(");
StartupSource.ShouldContain("SpaStaticFileRoot");
StartupSource.ShouldContain("RequestPath = \"/app\"");
StartupSource.ShouldContain("appIndexFile");
}
[Test]
public void Startup_Should_Not_Send_App_Routes_To_Blazor_Fallback()
{
StartupSource.ShouldContain("!IsSpaPath(ctx.Request.Path)");
StartupSource.ShouldContain("bool IsSpaPath(PathString path)");
}
private static string FindStartupPath()
{
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
while (directory is not null)
{
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs");
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs");
}
}
@@ -35,7 +35,12 @@ public abstract class ChannelHandlerTestBase
await context.SaveChangesAsync();
}
protected async Task SeedChannel(int id, string number, string name = "Test", int ffmpegProfileId = 1)
protected async Task SeedChannel(
int id,
string number,
string name = "Test",
int ffmpegProfileId = 1,
string group = "ErsatzTV")
{
await using TvContext context = Db.CreateContext();
context.Channels.Add(
@@ -44,7 +49,7 @@ public abstract class ChannelHandlerTestBase
Id = id,
Number = number,
Name = name,
Group = "ErsatzTV",
Group = group,
Categories = string.Empty,
FFmpegProfileId = ffmpegProfileId,
StreamSelector = string.Empty,
@@ -1,85 +0,0 @@
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}"
};
}
}
+54 -39
View File
@@ -6,7 +6,6 @@ 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;
@@ -22,19 +21,12 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
[EndpointGroupName("general")]
public async Task<List<ChannelResponseModel>> GetAll() => await mediator.Send(new GetAllChannelsForApi());
[HttpGet("/api/guide")]
[HttpGet("/api/channels/state")]
[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.")]
[EndpointSummary("Get channel runtime state")]
[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);
public async Task<List<ChannelStateResponseModel>> GetState(CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelStatesForApi(DateTime.UtcNow), cancellationToken);
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
[Tags("Channels")]
@@ -102,45 +94,68 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
return result.ToDeletedResult();
}
[HttpPost("/api/channels/bulk/renumber")]
[Tags("Channels")]
[EndpointSummary("Renumber channels")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> BulkRenumber(
[Required] [FromBody] BulkRenumberChannelsRequest request,
CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.Match<IActionResult>(
Some: error => error.ToErrorResult(),
None: () => new NoContentResult());
}
[HttpPost("/api/channels/bulk/group")]
[Tags("Channels")]
[EndpointSummary("Move channels to a group")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> BulkMoveToGroup(
[Required] [FromBody] BulkMoveChannelsToGroupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/channels/bulk/delete")]
[Tags("Channels")]
[EndpointSummary("Delete channels")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> BulkDelete(
[Required] [FromBody] BulkDeleteChannelsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToDeletedResult();
}
[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,
[FromQuery] PlayoutBuildMode? mode,
CancellationToken cancellationToken)
public async Task<IActionResult> ResetPlayout(string channelNumber)
{
Option<int> maybePlayoutId =
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
foreach (int playoutId in maybePlayoutId)
{
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset));
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);
}
}
@@ -1,19 +0,0 @@
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);
}
@@ -1,19 +0,0 @@
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);
}
@@ -1,18 +0,0 @@
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,23 +1,14 @@
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) : ControllerBase
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator)
{
[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")]
@@ -1,19 +0,0 @@
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);
}
+1 -95
View File
@@ -3,8 +3,6 @@ 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;
@@ -15,32 +13,6 @@ 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")]
@@ -53,34 +25,6 @@ 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")]
@@ -105,17 +49,6 @@ 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")]
@@ -138,32 +71,5 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.PlayoutMode,
vm.ScheduleName,
vm.ScheduleFile,
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));
vm.DbDailyRebuildTime);
}
@@ -0,0 +1,21 @@
using ErsatzTV.Application.Channels;
namespace ErsatzTV.Controllers.Api.Requests;
public record BulkRenumberChannelRequest(int Id, string Number);
public record BulkRenumberChannelsRequest(List<BulkRenumberChannelRequest> Channels)
{
public UpdateChannelNumbers ToCommand() =>
new(Channels.Select(c => new ChannelSortViewModel { Id = c.Id, Number = c.Number }).ToList());
}
public record BulkMoveChannelsToGroupRequest(List<int> ChannelIds, string Group)
{
public BulkMoveChannelsToGroup ToCommand() => new(ChannelIds, Group);
}
public record BulkDeleteChannelsRequest(List<int> ChannelIds)
{
public BulkDeleteChannels ToCommand() => new(ChannelIds);
}
@@ -99,12 +99,8 @@ 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(ProgramScheduleItemsWithDurationViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(List<ProgramScheduleItemViewModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
{
@@ -114,8 +110,8 @@ public class ScheduleController(IMediator mediator) : ControllerBase
return ApiResults.NotFoundProblem();
}
ProgramScheduleItemsWithDurationViewModel items =
await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken);
List<ProgramScheduleItemViewModel> items =
await mediator.Send(new GetProgramScheduleItems(id), cancellationToken);
return new OkObjectResult(items);
}
@@ -1,19 +0,0 @@
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);
}
+46 -1
View File
@@ -681,7 +681,42 @@ public class Startup
});
app.MapWhen(
ctx => !IsIptvPath(ctx.Request.Path),
ctx => ctx.Request.Path.StartsWithSegments("/app"),
spa =>
{
string spaStaticFileRoot = SpaStaticFileRoot();
IFileProvider spaFileProvider = Directory.Exists(spaStaticFileRoot)
? new PhysicalFileProvider(spaStaticFileRoot)
: new NullFileProvider();
spa.UseStaticFiles(
new StaticFileOptions
{
FileProvider = spaFileProvider,
RequestPath = "/app"
});
spa.Run(
async context =>
{
Microsoft.Extensions.FileProviders.IFileInfo appIndexFile =
spaFileProvider.GetFileInfo("index.html");
if (!appIndexFile.Exists)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.ContentLength = appIndexFile.Length;
await using Stream stream = appIndexFile.CreateReadStream();
await stream.CopyToAsync(context.Response.Body);
});
});
app.MapWhen(
ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path),
blazor =>
{
blazor.UseRouting();
@@ -736,6 +771,16 @@ public class Startup
path.StartsWithSegments("/lineup.json") ||
path.StartsWithSegments("/lineup_status.json");
}
bool IsSpaPath(PathString path) => path.StartsWithSegments("/app");
string SpaStaticFileRoot()
{
string webRootPath = CurrentEnvironment.WebRootPath ??
Path.Combine(CurrentEnvironment.ContentRootPath, "wwwroot");
return Path.Combine(webRootPath, "app");
}
}
private static void CustomServices(IServiceCollection services)

Some files were not shown because too many files have changed in this diff Show More