Compare commits

...
Author SHA1 Message Date
timothyandClaude Fable 5 3d086aabc1 feat(api): artwork upload endpoint (#104)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
POST /api/artwork/uploads (multipart/form-data) accepting logo and
watermark images. Validates content type (png/jpeg/gif/webp) and size
(SystemEnvironment.MaximumUploadMb, default 10MB) mirroring the Blazor
upload path; stores via IImageCache.SaveArtworkToCache; returns
{ path, contentType } consumable by channel create/update.

Includes handler + controller tests, OpenAPI 422 contract-test entry,
and regenerated v1.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:32:22 +02:00
timothy b9955f4cba Merge pull request 'feat(api): media sources + scan status endpoints (#103 #106)' (#115) from feat/103-media-sources-api into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m27s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m58s
2026-07-03 21:10:37 +00:00
timothy 69486ab2d6 chore(openapi): regenerate v1 for media sources api (#103 #106)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m15s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-03 23:03:05 +02:00
timothy 1b7b0e549a feat(api): add media sources and scan status endpoints (#103 #106) 2026-07-03 23:02:58 +02:00
timothy abd8ca34c9 Merge pull request 'feat(api): playout read endpoints (#100 #101 #107 #110)' (#116) from feat/playouts-read-api into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m35s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m42s
2026-07-03 20:50:06 +00:00
timothyandClaude Fable 5 20ca71b388 feat(api): playout read endpoints + reset-all (#100 #101 #107 #110)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 2m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- GET /api/playouts — paged list over GetPagedPlayouts (query/pageNum/pageSize), list DTO with buildStatus (#100, #107)
- GET /api/playouts/{id}/items — paged future items + UNSCHEDULED gaps over GetFuturePlayoutItemsById; 404 ProblemDetails for unknown playout (matches /api/schedules/{id}/items precedent) (#101)
- buildStatus {lastBuild, success, message} on GET /api/playouts/{id} (BuildStatus now included by GetPlayoutByIdHandler) (#107)
- GET /api/playouts/warnings/count — failed-build count for the warnings badge (#107)
- POST /api/playouts/reset-all — 202 Accepted, wraps ResetAllPlayouts (#110)
- POST /api/channels/{channelNumber}/playout/reset — optional ?mode= override; default branches by ScheduleKind (Classic→Refresh, others→Reset) to match Blazor semantics (#110)
- Option<FillerKind>→FillerKind? projection uses MatchUnsafe (Match throws on null-returning branch; idiom per Health/Mapper.cs)
- Tests: controller unit tests (routes, projections, 404s, reset-mode defaults), OpenAPI ProblemDetails contract entry for /api/playouts/{id}/items get 404
- Regenerated wwwroot/openapi/v1.json

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:28:09 +02:00
timothy 335a8b8a77 Merge pull request 'feat(api): health endpoints (#108)' (#114) from feat/108-health-api into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m39s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m15s
2026-07-03 19:28:13 +00:00
timothyandClaude Fable 5 922bec4b24 refactor(api): throw on unmapped health status instead of silent NotApplicable fallthrough (#108)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Review finding: NotApplicable is filtered by the handler before mapping,
so a silent ->"info" arm would only mask a missing filter if the mapper
is ever reused unfiltered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:18:41 +02:00
timothyandClaude Fable 5 272174ee75 chore(openapi): regenerate v1.json for health endpoint (#108)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:15:38 +02:00
timothyandClaude Fable 5 29407f637b fix(api): align health endpoint with API conventions (#108)
Fix the WIP health-check API slice to match established patterns:

- HealthController: add Name="GetHealthChecks" route name and move
  [EndpointGroupName("general")] to method level, matching
  FillerPresetController/FFmpegProfileController exactly (the
  precedent for parameterless 200-only GET actions).
- HealthCheckResponseModel: enable #nullable for the file and mark
  Link as string? since the mapper can emit null when
  HealthCheckResult.Link is None.
- Mapper: fix a real bug - LanguageExt's Option.Match throws
  ResultIsNullException.ResultIsNull if either branch returns null
  (by design, to catch accidental nulls). The WIP's
  `Link.Match(l => l.Link, () => null)` crashed on every health
  check without a Link. Switch to MatchUnsafe, the LanguageExt-
  sanctioned way to intentionally produce a nullable result from
  Option<T>.
- HealthControllerTests: add the idiomatic-route-assertion test
  (route template + Name) and empty-list case, matching
  FillerPresetControllerTests.

Verified: GetAllHealthCheckResultsForApiHandler already matches the
existing GetAllHealthCheckResultsHandler's cancellation handling
(both swallow TaskCanceledException/OperationCanceledException), so
no change was needed there. Confirmed no OpenApi contract test
enumerates all endpoints for error-response metadata (it's an
explicit TestCase allowlist), so the new GET needed no new entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:14:42 +02:00
timothyandClaude Fable 5 83c9122b6f wip: partial implementation salvaged from interrupted workflow run
Untrusted draft — no build/test had run yet. Review before building on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:07:21 +02:00
timothy 86f07594e4 Merge pull request 'feat(api): filler/watermark/graphics list endpoints (#105)' (#113) from feat/105-picker-list-endpoints into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m50s
2026-07-02 20:35:31 +00:00
timothy 446f50763a chore(openapi): regenerate v1.json for picker list endpoints (#105)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adds /api/filler-presets, /api/watermarks, and /api/graphics-elements
to the published OpenAPI document via ./scripts/update-openapi.sh.
2026-07-02 22:06:24 +02:00
timothy 0eaedb9cf6 feat(api): add route names and handler tests for picker list endpoints (#105)
The WIP GET /api/filler-presets, /api/watermarks, and
/api/graphics-elements endpoints omitted the Name= route attribute
used by the other API GET actions (e.g. FFmpegProfileController). Add
Name = "GetFillerPresets" / "GetWatermarks" / "GetGraphicsElements" for
consistency.

Also add handler tests (FillerPresetHandlerTests, WatermarkHandlerTests,
GraphicsElementHandlerTests) covering the GetAll*ForApi handlers using
the InMemoryTvContext harness: multi-row projection to (Id, Name) DTOs,
the empty-DB case, and the graphics-element ordering rule (named
elements sort before elements whose projected Name equals FileName).
2026-07-02 22:06:19 +02:00
timothyandClaude Fable 5 8912686a47 wip: partial implementation salvaged from interrupted workflow run
Untrusted draft — no build/test had run yet. Review before building on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:27:40 +02:00
timothy d1dfe6eb5a docs: add Codex verification guidance
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m23s
2026-07-01 22:39:16 +02:00
62 changed files with 3140 additions and 80 deletions
+15
View File
@@ -0,0 +1,15 @@
# Codex Instructions
## Verification Commands
Run .NET restore, build, and test commands outside the sandbox by default in this repo. Sandboxed .NET commands can stall on NuGet/package/compiler cache access, while the same commands complete normally with approved unsandboxed execution.
Preferred verification commands:
```bash
TZ=UTC dotnet restore ErsatzTV.sln -v minimal
TZ=UTC dotnet build ErsatzTV.sln --no-restore -v minimal
TZ=UTC dotnet test ErsatzTV.sln --no-build -v minimal
```
Use scoped escalated execution for these commands rather than first trying a sandboxed run.
@@ -0,0 +1,13 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
/// <summary>
/// Validates and stores an uploaded image as channel logo or watermark artwork,
/// landing it in the same on-disk cache the Blazor UI uses (via <c>IImageCache</c>),
/// so the returned path is equivalent to a Blazor-uploaded image.
/// </summary>
public record UploadArtwork(Stream Stream, string ContentType, ArtworkKind ArtworkKind)
: IRequest<Either<BaseError, ArtworkUploadResponseModel>>;
@@ -0,0 +1,53 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
namespace ErsatzTV.Application.Artworks;
public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
{
// png/jpeg/gif/webp are all decoded by SkiaSharp and read by FFmpeg, matching the
// formats the Blazor logo/watermark upload already accepts. Format expansion is ersatztv#66.
private static readonly System.Collections.Generic.HashSet<string> AcceptedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/png",
"image/jpeg",
"image/gif",
"image/webp"
};
private readonly IImageCache _imageCache;
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
public async Task<Either<BaseError, ArtworkUploadResponseModel>> Handle(
UploadArtwork request,
CancellationToken cancellationToken)
{
string contentType = (request.ContentType ?? string.Empty).Trim();
if (!AcceptedContentTypes.Contains(contentType))
{
return BaseError.New(
$"Unsupported image content type '{contentType}'; supported types are: {string.Join(", ", AcceptedContentTypes)}");
}
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
request.Stream,
request.ArtworkKind);
return maybeFileName.Map(fileName => new ArtworkUploadResponseModel(
BuildPath(request.ArtworkKind, fileName),
contentType));
}
// Mirror the on-disk conventions the Blazor editors use so the returned path is a drop-in
// for ArtworkContentTypeModel.Path: channel logos are addressed as "iptv/logos/{file}"
// (see ChannelEditor.UploadLogo), watermarks by the bare cache file name (see WatermarkEditor).
private static string BuildPath(ArtworkKind artworkKind, string fileName) =>
artworkKind switch
{
ArtworkKind.Logo => $"iptv/logos/{fileName}",
_ => fileName
};
}
+5 -1
View File
@@ -1,9 +1,13 @@
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Filler;
internal static class Mapper
{
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
new(fillerPreset.Id, fillerPreset.Name);
internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) =>
new(
fillerPreset.Id,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Filler;
namespace ErsatzTV.Application.Filler;
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Filler.Mapper;
namespace ErsatzTV.Application.Filler;
public class GetAllFillerPresetsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllFillerPresetsForApi, List<FillerPresetResponseModel>>
{
public async Task<List<FillerPresetResponseModel>> Handle(
GetAllFillerPresetsForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<FillerPreset> fillerPresets = await dbContext.FillerPresets
.AsNoTracking()
.ToListAsync(cancellationToken);
return fillerPresets.Map(ProjectToResponseModel).ToList();
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Graphics;
namespace ErsatzTV.Application.Graphics;
public record GetAllGraphicsElementsForApi : IRequest<List<GraphicsElementResponseModel>>;
@@ -0,0 +1,27 @@
using ErsatzTV.Core.Api.Graphics;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Graphics.Mapper;
namespace ErsatzTV.Application.Graphics;
public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllGraphicsElementsForApi, List<GraphicsElementResponseModel>>
{
public async Task<List<GraphicsElementResponseModel>> Handle(
GetAllGraphicsElementsForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<GraphicsElement> graphicsElements = await dbContext.GraphicsElements
.AsNoTracking()
.ToListAsync(cancellationToken);
return graphicsElements
.Map(ProjectToViewModel)
.OrderBy(e => e.Name == e.FileName)
.ThenBy(e => e.Name)
.Select(vm => new GraphicsElementResponseModel(vm.Id, vm.Name))
.ToList();
}
}
+24
View File
@@ -0,0 +1,24 @@
using ErsatzTV.Core.Api.Health;
using ErsatzTV.Core.Health;
namespace ErsatzTV.Application.Health;
internal static class Mapper
{
internal static HealthCheckResponseModel ProjectToResponseModel(HealthCheckResult result) =>
new(
result.Title,
GetStatus(result.Status),
result.Message,
result.Link.MatchUnsafe(l => l.Link, () => null));
private static string GetStatus(HealthCheckStatus status) =>
status switch
{
HealthCheckStatus.Pass => "pass",
HealthCheckStatus.Fail => "fail",
HealthCheckStatus.Warning => "warn",
HealthCheckStatus.Info => "info",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
};
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Health;
namespace ErsatzTV.Application.Health;
public record GetAllHealthCheckResultsForApi : IRequest<List<HealthCheckResponseModel>>;
@@ -0,0 +1,32 @@
using ErsatzTV.Core.Api.Health;
using ErsatzTV.Core.Health;
using static ErsatzTV.Application.Health.Mapper;
namespace ErsatzTV.Application.Health;
public class GetAllHealthCheckResultsForApiHandler
: IRequestHandler<GetAllHealthCheckResultsForApi, List<HealthCheckResponseModel>>
{
private readonly IHealthCheckService _healthCheckService;
public GetAllHealthCheckResultsForApiHandler(IHealthCheckService healthCheckService) =>
_healthCheckService = healthCheckService;
public async Task<List<HealthCheckResponseModel>> Handle(
GetAllHealthCheckResultsForApi request,
CancellationToken cancellationToken)
{
try
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
return results
.Filter(r => r.Status != HealthCheckStatus.NotApplicable)
.Map(ProjectToResponseModel)
.ToList();
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return [];
}
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Libraries;
namespace ErsatzTV.Application.Libraries;
public record GetLibraryScanStatus : IRequest<List<LibraryScanStatusResponseModel>>;
@@ -0,0 +1,20 @@
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Application.Libraries;
public class GetLibraryScanStatusHandler(IScannerProxyService scannerProxyService)
: IRequestHandler<GetLibraryScanStatus, List<LibraryScanStatusResponseModel>>
{
public Task<List<LibraryScanStatusResponseModel>> Handle(
GetLibraryScanStatus request,
CancellationToken cancellationToken)
{
List<LibraryScanStatusResponseModel> result = scannerProxyService.GetActiveScans()
.Select(scan => new LibraryScanStatusResponseModel(scan.LibraryId, scan.Progress))
.ToList();
return Task.FromResult(result);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.MediaSources;
namespace ErsatzTV.Application.MediaSources;
public record GetAllMediaSourcesForApi : IRequest<List<MediaSourceResponseModel>>;
@@ -0,0 +1,148 @@
#nullable enable
using Dapper;
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaSources;
public class GetAllMediaSourcesForApiHandler(
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
{
public async Task<List<MediaSourceResponseModel>> Handle(
GetAllMediaSourcesForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<MediaSource> mediaSources = await dbContext.MediaSources
.AsNoTracking()
.Include(s => s.Libraries)
.ThenInclude(l => l.Paths)
.ToListAsync(cancellationToken);
Dictionary<int, int> itemCountsByLibrary = await GetItemCountsByLibrary(dbContext, cancellationToken);
Dictionary<int, string> addressByMediaSourceId = await GetConnectionAddresses(dbContext, cancellationToken);
var result = new List<MediaSourceResponseModel>();
foreach (MediaSource mediaSource in mediaSources)
{
List<MediaSourceLibraryResponseModel> libraryModels = mediaSource.Libraries
.Filter(ShouldIncludeLibrary)
.OrderBy(l => l.MediaKind)
.ThenBy(l => l.Name)
.Map(l => new MediaSourceLibraryResponseModel(
l.Id,
l.Name,
l.MediaKind,
l.LastScan,
itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
.ToList();
string? address = addressByMediaSourceId.TryGetValue(mediaSource.Id, out string? a) ? a : null;
result.Add(
new MediaSourceResponseModel(
mediaSource.Id,
GetKind(mediaSource),
GetName(mediaSource),
address,
libraryModels));
}
return result
.OrderBy(s => s.Kind == "Local" ? 0 : 1)
.ThenBy(s => s.Kind)
.ThenBy(s => s.Name)
.ToList();
}
private static async Task<Dictionary<int, int>> GetItemCountsByLibrary(
TvContext dbContext,
CancellationToken cancellationToken)
{
IEnumerable<LibraryItemCount> counts = await dbContext.Connection.QueryAsync<LibraryItemCount>(
new CommandDefinition(
@"SELECT LP.LibraryId AS LibraryId, COUNT(*) AS Count
FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
GROUP BY LP.LibraryId",
cancellationToken: cancellationToken));
return counts.ToDictionary(c => (int)c.LibraryId, c => (int)c.Count);
}
private static async Task<Dictionary<int, string>> GetConnectionAddresses(
TvContext dbContext,
CancellationToken cancellationToken)
{
var addresses = new Dictionary<int, string>();
foreach (PlexMediaSource plex in await dbContext.PlexMediaSources
.AsNoTracking()
.Include(s => s.Connections)
.ToListAsync(cancellationToken))
{
foreach (PlexConnection connection in Optional(plex.Connections.SingleOrDefault(c => c.IsActive)))
{
addresses[plex.Id] = connection.Uri;
}
}
foreach (JellyfinMediaSource jellyfin in await dbContext.JellyfinMediaSources
.AsNoTracking()
.Include(s => s.Connections)
.ToListAsync(cancellationToken))
{
foreach (JellyfinConnection connection in jellyfin.Connections.HeadOrNone())
{
addresses[jellyfin.Id] = connection.Address;
}
}
foreach (EmbyMediaSource emby in await dbContext.EmbyMediaSources
.AsNoTracking()
.Include(s => s.Connections)
.ToListAsync(cancellationToken))
{
foreach (EmbyConnection connection in emby.Connections.HeadOrNone())
{
addresses[emby.Id] = connection.Address;
}
}
return addresses;
}
private static bool ShouldIncludeLibrary(Library library) =>
library switch
{
LocalLibrary => library.Paths.Count > 0,
PlexLibrary plex => plex.ShouldSyncItems,
JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems,
EmbyLibrary emby => emby.ShouldSyncItems,
_ => false
};
private static string GetKind(MediaSource mediaSource) =>
mediaSource switch
{
PlexMediaSource => "Plex",
JellyfinMediaSource => "Jellyfin",
EmbyMediaSource => "Emby",
_ => "Local"
};
private static string GetName(MediaSource mediaSource) =>
mediaSource switch
{
PlexMediaSource plex => plex.ServerName,
JellyfinMediaSource jellyfin => jellyfin.ServerName,
EmbyMediaSource emby => emby.ServerName,
_ => "Local"
};
private sealed record LibraryItemCount(long LibraryId, long Count);
}
@@ -16,6 +16,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
.AsNoTracking()
.Include(p => p.ProgramSchedule)
.Include(p => p.Channel)
.Include(p => p.BuildStatus)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
.MapT(p => new PlayoutNameViewModel(
p.Id,
@@ -1,10 +1,14 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Watermarks;
internal static class Mapper
{
internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
new(watermark.Id, watermark.Name);
public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) =>
new(
watermark.Id,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Watermarks;
namespace ErsatzTV.Application.Watermarks;
public record GetAllWatermarksForApi : IRequest<List<WatermarkResponseModel>>;
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Watermarks.Mapper;
namespace ErsatzTV.Application.Watermarks;
public class GetAllWatermarksForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllWatermarksForApi, List<WatermarkResponseModel>>
{
public async Task<List<WatermarkResponseModel>> Handle(
GetAllWatermarksForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<ChannelWatermark> watermarks = await dbContext.ChannelWatermarks
.AsNoTracking()
.ToListAsync(cancellationToken);
return watermarks.Map(ProjectToResponseModel).ToList();
}
}
@@ -0,0 +1,10 @@
#nullable enable
namespace ErsatzTV.Core.Api.Artwork;
/// <summary>
/// Result of uploading channel logo / watermark artwork via the REST API.
/// <see cref="Path" /> is directly consumable as the <c>Path</c> of an
/// <c>ArtworkContentTypeModel</c> (e.g. <c>CreateChannel.Logo</c> / channel update),
/// and <see cref="ContentType" /> carries the stored MIME type.
/// </summary>
public record ArtworkUploadResponseModel(string Path, string ContentType);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Api.Filler;
public record FillerPresetResponseModel(int Id, string Name);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Api.Graphics;
public record GraphicsElementResponseModel(int Id, string Name);
@@ -0,0 +1,8 @@
#nullable enable
namespace ErsatzTV.Core.Api.Health;
public record HealthCheckResponseModel(
string Title,
string Status,
string Detail,
string? Link);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Api.Libraries;
public record LibraryScanStatusResponseModel(int LibraryId, decimal Percent);
@@ -0,0 +1,11 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaSources;
public record MediaSourceLibraryResponseModel(
int Id,
string Name,
LibraryMediaKind MediaKind,
DateTime? LastScan,
int ItemCount);
@@ -0,0 +1,9 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record MediaSourceResponseModel(
int Id,
string Kind,
string Name,
string? ConnectionAddress,
List<MediaSourceLibraryResponseModel> Libraries);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutItemsResponseModel(
int TotalCount,
List<PlayoutItemResponseModel> Page);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutsResponseModel(
int TotalCount,
List<PlayoutListItemResponseModel> Page);
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutBuildStatusResponseModel(
DateTimeOffset LastBuild,
bool Success,
string Message);
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutItemResponseModel(
string Title,
DateTimeOffset Start,
DateTimeOffset Finish,
string Duration,
FillerKind? FillerKind);
@@ -0,0 +1,13 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutListItemResponseModel(
int Id,
string ChannelNumber,
string ChannelName,
PlayoutScheduleKind ScheduleKind,
string ScheduleName,
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus);
@@ -1,3 +1,4 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Playouts;
@@ -9,8 +10,9 @@ public record PlayoutResponseModel(
string ChannelNumber,
ChannelPlayoutMode PlayoutMode,
string ScheduleName,
string ScheduleFile,
TimeSpan? DailyRebuildTime)
string? ScheduleFile,
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus)
{
public static PlayoutResponseModel From(
int id,
@@ -19,8 +21,9 @@ public record PlayoutResponseModel(
string channelNumber,
ChannelPlayoutMode playoutMode,
string scheduleName,
string scheduleFile,
TimeSpan? dailyRebuildTime) =>
string? scheduleFile,
TimeSpan? dailyRebuildTime,
PlayoutBuildStatusResponseModel? buildStatus) =>
new(
id,
scheduleKind,
@@ -29,5 +32,6 @@ public record PlayoutResponseModel(
playoutMode,
scheduleName,
scheduleFile,
dailyRebuildTime);
dailyRebuildTime,
buildStatus);
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Api.Watermarks;
public record WatermarkResponseModel(int Id, string Name);
@@ -1,3 +1,5 @@
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Metadata;
public interface IScannerProxyService
@@ -7,4 +9,5 @@ public interface IScannerProxyService
Task Progress(Guid scanId, decimal percentComplete);
bool IsActive(Guid scanId);
Option<decimal> GetProgress(int libraryId);
IReadOnlyList<LibraryScanProgress> GetActiveScans();
}
@@ -49,4 +49,7 @@ public class ScannerProxyService(IMediator mediator) : IScannerProxyService
public Option<decimal> GetProgress(int libraryId) => _activeLibraries.TryGetValue(libraryId, out decimal progress)
? progress
: Option<decimal>.None;
public IReadOnlyList<LibraryScanProgress> GetActiveScans() =>
_activeLibraries.Select(kvp => new LibraryScanProgress(kvp.Key, kvp.Value)).ToList();
}
@@ -0,0 +1,87 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Application.Artworks;
[TestFixture]
public class UploadArtworkHandlerTests
{
private IImageCache _imageCache = null!;
private UploadArtworkHandler _handler = null!;
[SetUp]
public void SetUp()
{
_imageCache = Substitute.For<IImageCache>();
_handler = new UploadArtworkHandler(_imageCache);
}
[Test]
public async Task Handle_Should_Return_Logo_Path_With_Iptv_Logos_Prefix()
{
_imageCache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Logo)
.Returns(Right<BaseError, string>("abc123.png"));
using var stream = new MemoryStream();
Either<BaseError, ArtworkUploadResponseModel> result =
await _handler.Handle(new UploadArtwork(stream, "image/png", ArtworkKind.Logo), CancellationToken.None);
ArtworkUploadResponseModel response = RightOf(result);
response.Path.ShouldBe("iptv/logos/abc123.png");
response.ContentType.ShouldBe("image/png");
}
[Test]
public async Task Handle_Should_Return_Bare_File_Name_For_Watermark()
{
_imageCache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Watermark)
.Returns(Right<BaseError, string>("def456.webp"));
using var stream = new MemoryStream();
Either<BaseError, ArtworkUploadResponseModel> result = await _handler.Handle(
new UploadArtwork(stream, "image/webp", ArtworkKind.Watermark),
CancellationToken.None);
RightOf(result).Path.ShouldBe("def456.webp");
}
[Test]
public async Task Handle_Should_Reject_Unsupported_Content_Type()
{
using var stream = new MemoryStream();
Either<BaseError, ArtworkUploadResponseModel> result = await _handler.Handle(
new UploadArtwork(stream, "image/bmp", ArtworkKind.Logo),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("Unsupported image content type");
await _imageCache.DidNotReceive().SaveArtworkToCache(Arg.Any<Stream>(), Arg.Any<ArtworkKind>());
}
[Test]
public async Task Handle_Should_Propagate_Cache_Save_Failure()
{
_imageCache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Logo)
.Returns(Left<BaseError, string>(BaseError.New("disk full")));
using var stream = new MemoryStream();
Either<BaseError, ArtworkUploadResponseModel> result = await _handler.Handle(
new UploadArtwork(stream, "image/png", ArtworkKind.Logo),
CancellationToken.None);
LeftOf(result).Value.ShouldBe("disk full");
}
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Right: v => v, Left: e => throw new AssertionException($"Expected Right, got Left: {e.Value}"));
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Right: _ => throw new AssertionException("Expected Left, got Right"), Left: e => e);
}
@@ -0,0 +1,63 @@
using ErsatzTV.Application.Filler;
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Filler;
[TestFixture]
public class FillerPresetHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task GetAllFillerPresetsForApi_Should_Return_All_Presets()
{
await SeedPreset(1, "Intro");
await SeedPreset(2, "Outro");
var handler = new GetAllFillerPresetsForApiHandler(_db.Factory);
List<FillerPresetResponseModel> result =
await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None);
result.Count.ShouldBe(2);
result.ShouldContain(new FillerPresetResponseModel(1, "Intro"));
result.ShouldContain(new FillerPresetResponseModel(2, "Outro"));
}
[Test]
public async Task GetAllFillerPresetsForApi_Should_Return_Empty_List_When_None_Exist()
{
var handler = new GetAllFillerPresetsForApiHandler(_db.Factory);
List<FillerPresetResponseModel> result =
await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None);
result.ShouldBeEmpty();
}
private async Task SeedPreset(int id, string name)
{
await using TvContext context = _db.CreateContext();
context.FillerPresets.Add(new FillerPreset
{
Id = id,
Name = name,
FillerKind = FillerKind.PreRoll,
FillerMode = FillerMode.Duration,
CollectionType = CollectionType.Collection
});
await context.SaveChangesAsync();
}
}
@@ -0,0 +1,77 @@
using ErsatzTV.Application.Graphics;
using ErsatzTV.Core.Api.Graphics;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Graphics;
[TestFixture]
public class GraphicsElementHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Return_All_Elements()
{
await SeedElement(1, "watermark.png", GraphicsElementKind.Image, "Custom Watermark");
await SeedElement(2, "clock.png", GraphicsElementKind.Image, string.Empty);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(2);
result.Select(e => e.Id).ShouldBe([1, 2]);
}
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Order_Named_Elements_Before_Unnamed()
{
// unnamed element's projected Name equals its FileName -> should sort after named elements
await SeedElement(1, "aaa.png", GraphicsElementKind.Image, string.Empty);
await SeedElement(2, "zzz.png", GraphicsElementKind.Image, "A Custom Name");
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(2);
result[0].Id.ShouldBe(2);
result[1].Id.ShouldBe(1);
}
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Return_Empty_List_When_None_Exist()
{
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.ShouldBeEmpty();
}
private async Task SeedElement(int id, string path, GraphicsElementKind kind, string name)
{
await using TvContext context = _db.CreateContext();
context.GraphicsElements.Add(new GraphicsElement
{
Id = id,
Path = path,
Name = name,
Kind = kind
});
await context.SaveChangesAsync();
}
}
@@ -0,0 +1,111 @@
using ErsatzTV.Application.Health;
using ErsatzTV.Core.Api.Health;
using ErsatzTV.Core.Health;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Health;
[TestFixture]
public class GetAllHealthCheckResultsForApiHandlerTests
{
private IHealthCheckService _healthCheckService = null!;
private GetAllHealthCheckResultsForApiHandler _handler = null!;
[SetUp]
public void SetUp()
{
_healthCheckService = Substitute.For<IHealthCheckService>();
_handler = new GetAllHealthCheckResultsForApiHandler(_healthCheckService);
}
[Test]
public async Task Should_Map_Status_Codes_To_Lowercase_Strings()
{
var results = new List<HealthCheckResult>
{
new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option<HealthCheckLink>.None),
new("Fail Check", HealthCheckStatus.Fail, "broken", "bad", Option<HealthCheckLink>.None),
new("Warn Check", HealthCheckStatus.Warning, "watch out", "warn", Option<HealthCheckLink>.None),
new("Info Check", HealthCheckStatus.Info, "fyi", "info", Option<HealthCheckLink>.None)
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response.Count.ShouldBe(4);
response.Select(r => r.Status).ShouldBe(["pass", "fail", "warn", "info"]);
}
[Test]
public async Task Should_Filter_Out_NotApplicable_Results()
{
var results = new List<HealthCheckResult>
{
new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option<HealthCheckLink>.None),
new("NA Check", HealthCheckStatus.NotApplicable, "skip", "skip", Option<HealthCheckLink>.None)
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response.Count.ShouldBe(1);
response[0].Title.ShouldBe("Pass Check");
}
[Test]
public async Task Should_Include_Link_When_Present()
{
var results = new List<HealthCheckResult>
{
new(
"Linked Check",
HealthCheckStatus.Warning,
"detail message",
"brief",
Option<HealthCheckLink>.Some(new HealthCheckLink("https://example.com/docs")))
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response[0].Link.ShouldBe("https://example.com/docs");
response[0].Detail.ShouldBe("detail message");
}
[Test]
public async Task Should_Have_Null_Link_When_Absent()
{
var results = new List<HealthCheckResult>
{
new("No Link Check", HealthCheckStatus.Pass, "detail", "brief", Option<HealthCheckLink>.None)
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response[0].Link.ShouldBeNull();
}
[Test]
public async Task Should_Return_Empty_List_On_Cancellation()
{
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>())
.Returns<Task<List<HealthCheckResult>>>(_ => throw new TaskCanceledException());
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response.ShouldBeEmpty();
}
}
@@ -0,0 +1,32 @@
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Metadata;
using MediatR;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Libraries;
[TestFixture]
public class GetLibraryScanStatusHandlerTests
{
[Test]
public async Task Handle_Should_Return_Active_Scans()
{
var mediator = Substitute.For<IMediator>();
var scannerProxyService = new ScannerProxyService(mediator);
Guid scanId = scannerProxyService.StartScan(42)
.Match(
Some: id => id,
None: () => throw new AssertionException("Expected scan to start"));
await scannerProxyService.Progress(scanId, 62.5m);
var handler = new GetLibraryScanStatusHandler(scannerProxyService);
List<LibraryScanStatusResponseModel> result =
await handler.Handle(new GetLibraryScanStatus(), CancellationToken.None);
result.ShouldBe([new LibraryScanStatusResponseModel(42, 62.5m)]);
}
}
@@ -0,0 +1,179 @@
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.MediaSources;
[TestFixture]
public class GetAllMediaSourcesForApiHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Group_Configured_Libraries_By_Source_With_Item_Counts()
{
await SeedMediaSources();
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
result.Count.ShouldBe(4);
result[0].Kind.ShouldBe("Local");
result[0].Name.ShouldBe("Local");
result[0].ConnectionAddress.ShouldBeNull();
result[0].Libraries.Single().Name.ShouldBe("Local Movies");
result[0].Libraries.Single().LastScan.ShouldBe(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc));
result[0].Libraries.Single().ItemCount.ShouldBe(2);
result[1].Kind.ShouldBe("Emby");
result[1].Name.ShouldBe("Emby Server");
result[1].ConnectionAddress.ShouldBe("http://emby.local");
result[1].Libraries.Single().Name.ShouldBe("Emby Shows");
result[1].Libraries.Single().ItemCount.ShouldBe(1);
result[2].Kind.ShouldBe("Jellyfin");
result[2].Name.ShouldBe("Jellyfin Server");
result[2].ConnectionAddress.ShouldBe("http://jellyfin.local");
result[2].Libraries.ShouldBeEmpty();
result[3].Kind.ShouldBe("Plex");
result[3].Name.ShouldBe("Plex Server");
result[3].ConnectionAddress.ShouldBe("http://plex.local");
result[3].Libraries.Single().Name.ShouldBe("Plex Movies");
result[3].Libraries.Single().ItemCount.ShouldBe(0);
}
[Test]
public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries()
{
await SeedMediaSources();
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
result.SelectMany(s => s.Libraries).Select(l => l.Name)
.ShouldNotContain("Empty Local");
result.SelectMany(s => s.Libraries).Select(l => l.Name)
.ShouldNotContain("Disabled Jellyfin");
}
private async Task SeedMediaSources()
{
await using TvContext context = _db.CreateContext();
var localSource = new LocalMediaSource
{
Libraries =
[
new LocalLibrary
{
Name = "Local Movies",
MediaKind = LibraryMediaKind.Movies,
LastScan = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
Paths = [MakePath("/media/movies", 2)]
},
new LocalLibrary
{
Name = "Empty Local",
MediaKind = LibraryMediaKind.Shows,
Paths = []
}
]
};
var plexSource = new PlexMediaSource
{
ServerName = "Plex Server",
ProductVersion = "1",
Platform = "Linux",
PlatformVersion = "1",
ClientIdentifier = "plex",
Connections = [new PlexConnection { IsActive = true, Uri = "http://plex.local" }],
PathReplacements = [],
Libraries =
[
new PlexLibrary
{
Name = "Plex Movies",
MediaKind = LibraryMediaKind.Movies,
Key = "1",
ShouldSyncItems = true,
Paths = []
}
]
};
var embySource = new EmbyMediaSource
{
ServerName = "Emby Server",
OperatingSystem = "Linux",
Connections = [new EmbyConnection { Address = "http://emby.local" }],
PathReplacements = [],
Libraries =
[
new EmbyLibrary
{
Name = "Emby Shows",
MediaKind = LibraryMediaKind.Shows,
ItemId = "emby-shows",
ShouldSyncItems = true,
PathInfos = [],
Paths = [MakePath("/emby/shows", 1)]
}
]
};
var jellyfinSource = new JellyfinMediaSource
{
ServerName = "Jellyfin Server",
OperatingSystem = "Linux",
Connections = [new JellyfinConnection { Address = "http://jellyfin.local" }],
PathReplacements = [],
Libraries =
[
new JellyfinLibrary
{
Name = "Disabled Jellyfin",
MediaKind = LibraryMediaKind.Movies,
ItemId = "jellyfin-movies",
ShouldSyncItems = false,
PathInfos = [],
Paths = [MakePath("/jellyfin/movies", 1)]
}
]
};
context.MediaSources.AddRange(localSource, plexSource, embySource, jellyfinSource);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
}
private static LibraryPath MakePath(string path, int mediaItemCount) =>
new()
{
Path = path,
LibraryFolders = [],
MediaItems = Enumerable.Range(0, mediaItemCount)
.Select(_ => new Movie
{
MovieMetadata = [],
MediaVersions = [],
Collections = [],
CollectionItems = [],
TraktListItems = []
})
.Cast<MediaItem>()
.ToList()
};
}
@@ -0,0 +1,71 @@
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Watermarks;
[TestFixture]
public class WatermarkHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task GetAllWatermarksForApi_Should_Return_All_Watermarks()
{
await SeedWatermark(1, "Bug");
await SeedWatermark(2, "Logo");
var handler = new GetAllWatermarksForApiHandler(_db.Factory);
List<WatermarkResponseModel> result =
await handler.Handle(new GetAllWatermarksForApi(), CancellationToken.None);
result.Count.ShouldBe(2);
result.ShouldContain(new WatermarkResponseModel(1, "Bug"));
result.ShouldContain(new WatermarkResponseModel(2, "Logo"));
}
[Test]
public async Task GetAllWatermarksForApi_Should_Return_Empty_List_When_None_Exist()
{
var handler = new GetAllWatermarksForApiHandler(_db.Factory);
List<WatermarkResponseModel> result =
await handler.Handle(new GetAllWatermarksForApi(), CancellationToken.None);
result.ShouldBeEmpty();
}
private async Task SeedWatermark(int id, string name)
{
await using TvContext context = _db.CreateContext();
context.ChannelWatermarks.Add(new ChannelWatermark
{
Id = id,
Name = name,
Mode = ChannelWatermarkMode.Permanent,
ImageSource = ChannelWatermarkImageSource.Custom,
Image = "watermark.png",
Location = WatermarkLocation.BottomRight,
Size = WatermarkSize.Scaled,
WidthPercent = 10,
HorizontalMarginPercent = 2,
VerticalMarginPercent = 2,
FrequencyMinutes = 15,
DurationSeconds = 30,
Opacity = 100
});
await context.SaveChangesAsync();
}
}
@@ -0,0 +1,164 @@
using System.Reflection;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ArtworkUploadControllerTests
{
private ArtworkUploadController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new ArtworkUploadController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(ArtworkUploadController).GetMethod(nameof(ArtworkUploadController.Upload))
?? throw new AssertionException("Missing action Upload");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("POST");
attribute.Template.ShouldBe("/api/artwork/uploads");
attribute.Name.ShouldBe("UploadArtwork");
}
[Test]
public void Action_Should_Consume_Multipart_Form_Data()
{
MethodInfo action = typeof(ArtworkUploadController).GetMethod(nameof(ArtworkUploadController.Upload))
?? throw new AssertionException("Missing action Upload");
var consumes = action.GetCustomAttribute<ConsumesAttribute>();
consumes.ShouldNotBeNull();
consumes.ContentTypes.ShouldContain("multipart/form-data");
}
[Test]
public async Task Upload_Should_Return_422_When_File_Missing()
{
IActionResult result = await _controller.Upload(null!, "logo", CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(422);
problem.Title.ShouldBe("Validation failed");
await _mediator.DidNotReceive().Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Upload_Should_Return_422_When_File_Empty()
{
IFormFile emptyFile = MakeFormFile([], "image/png");
IActionResult result = await _controller.Upload(emptyFile, "logo", CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Upload_Should_Return_422_When_File_Exceeds_Maximum_Size()
{
var oversizeBytes = new byte[(SystemEnvironment.MaximumUploadMb * 1024 * 1024) + 1];
IFormFile oversizeFile = MakeFormFile(oversizeBytes, "image/png");
IActionResult result = await _controller.Upload(oversizeFile, "logo", CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Detail.ShouldContain("maximum allowed size");
await _mediator.DidNotReceive().Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Upload_Should_Return_422_For_Unknown_Target()
{
IFormFile file = MakeFormFile([1, 2, 3], "image/png");
IActionResult result = await _controller.Upload(file, "poster", CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Detail.ShouldContain("Unknown upload target");
await _mediator.DidNotReceive().Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Upload_Should_Send_UploadArtwork_With_Logo_Kind_And_Return_201()
{
IFormFile file = MakeFormFile([1, 2, 3], "image/png");
_mediator.Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ArtworkUploadResponseModel>(
new ArtworkUploadResponseModel("iptv/logos/abc.png", "image/png")));
IActionResult result = await _controller.Upload(file, "logo", CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/iptv/logos/abc.png?contentType=image%2Fpng");
created.Value.ShouldBeOfType<ArtworkUploadResponseModel>()
.Path.ShouldBe("iptv/logos/abc.png");
await _mediator.Received(1).Send(
Arg.Is<UploadArtwork>(c => c.ArtworkKind == ArtworkKind.Logo && c.ContentType == "image/png"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Upload_Should_Send_UploadArtwork_With_Watermark_Kind_And_Return_201()
{
IFormFile file = MakeFormFile([1, 2, 3], "image/webp");
_mediator.Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ArtworkUploadResponseModel>(
new ArtworkUploadResponseModel("def.webp", "image/webp")));
IActionResult result = await _controller.Upload(file, "watermark", CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/artwork/watermarks/def.webp?contentType=image%2Fwebp");
await _mediator.Received(1).Send(
Arg.Is<UploadArtwork>(c => c.ArtworkKind == ArtworkKind.Watermark),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Upload_Should_Return_422_On_Handler_Validation_Error()
{
IFormFile file = MakeFormFile([1, 2, 3], "image/bmp");
_mediator.Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, ArtworkUploadResponseModel>(BaseError.New("unsupported content type")));
IActionResult result = await _controller.Upload(file, "logo", CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(422);
problem.Title.ShouldBe("Validation failed");
}
private static IFormFile MakeFormFile(byte[] bytes, string contentType) =>
new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "upload.bin")
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
@@ -8,6 +8,7 @@ using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
using static LanguageExt.Prelude;
using MediatR;
@@ -23,15 +24,15 @@ namespace ErsatzTV.Tests.Controllers;
public class ChannelControllerTests
{
private IMediator _mediator = null!;
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
private ChannelController _controller = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
ChannelWriter<IBackgroundServiceRequest> writer =
System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
_controller = new ChannelController(writer, _mediator);
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
_controller = new ChannelController(_workerChannel.Writer, _mediator);
}
[Test]
@@ -157,7 +158,7 @@ public class ChannelControllerTests
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.None);
IActionResult result = await _controller.ResetPlayout("404");
IActionResult result = await _controller.ResetPlayout("404", mode: null, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
@@ -165,6 +166,53 @@ public class ChannelControllerTests
problemDetails.Title.ShouldBe("Resource not found");
}
[TestCase(PlayoutScheduleKind.Classic, PlayoutBuildMode.Refresh)]
[TestCase(PlayoutScheduleKind.Block, PlayoutBuildMode.Reset)]
[TestCase(PlayoutScheduleKind.Sequential, PlayoutBuildMode.Reset)]
public async Task ResetPlayout_Should_Default_Mode_By_ScheduleKind(
PlayoutScheduleKind scheduleKind,
PlayoutBuildMode expectedMode)
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9, scheduleKind)));
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
result.ShouldBeOfType<OkResult>();
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
var buildPlayout = request.ShouldBeOfType<BuildPlayout>();
buildPlayout.PlayoutId.ShouldBe(9);
buildPlayout.Mode.ShouldBe(expectedMode);
}
[Test]
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None);
result.ShouldBeOfType<OkResult>();
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
request.ShouldBeOfType<BuildPlayout>().Mode.ShouldBe(PlayoutBuildMode.Continue);
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
}
private static PlayoutNameViewModel MakePlayout(int id, PlayoutScheduleKind scheduleKind) =>
new(
id,
scheduleKind,
"Channel",
"5",
ChannelPlayoutMode.Continuous,
"Schedule",
string.Empty,
null,
null);
private static ChannelViewModel MakeVm(int id) =>
new(
id,
@@ -0,0 +1,63 @@
using System.Reflection;
using ErsatzTV.Application.Filler;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.Filler;
using MediatR;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class FillerPresetControllerTests
{
private FillerPresetController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new FillerPresetController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(FillerPresetController).GetMethod(nameof(FillerPresetController.GetAll))
?? throw new AssertionException("Missing action GetAll");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/filler-presets");
}
[Test]
public async Task GetAll_Should_Return_FillerPresets()
{
List<FillerPresetResponseModel> models =
[
new FillerPresetResponseModel(1, "Intro"),
new FillerPresetResponseModel(2, "Outro")
];
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
.Returns(models);
List<FillerPresetResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(models);
}
[Test]
public async Task GetAll_Should_Return_Empty_List_When_None_Exist()
{
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
List<FillerPresetResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBeEmpty();
}
}
@@ -0,0 +1,63 @@
using System.Reflection;
using ErsatzTV.Application.Graphics;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.Graphics;
using MediatR;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class GraphicsElementControllerTests
{
private GraphicsElementController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new GraphicsElementController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(GraphicsElementController).GetMethod(nameof(GraphicsElementController.GetAll))
?? throw new AssertionException("Missing action GetAll");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/graphics-elements");
}
[Test]
public async Task GetAll_Should_Return_GraphicsElements()
{
List<GraphicsElementResponseModel> models =
[
new GraphicsElementResponseModel(1, "Lower Third (lower-third.png)"),
new GraphicsElementResponseModel(2, "bug.png")
];
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
.Returns(models);
List<GraphicsElementResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(models);
}
[Test]
public async Task GetAll_Should_Return_Empty_List_When_None_Exist()
{
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
List<GraphicsElementResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBeEmpty();
}
}
@@ -0,0 +1,65 @@
using System.Reflection;
using ErsatzTV.Application.Health;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.Health;
using MediatR;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class HealthControllerTests
{
private IMediator _mediator = null!;
private HealthController _controller = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new HealthController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(HealthController).GetMethod(nameof(HealthController.GetAll))
?? throw new AssertionException("Missing action GetAll");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/health");
attribute.Name.ShouldBe("GetHealthChecks");
}
[Test]
public async Task GetAll_Should_Return_Results_From_Mediator()
{
var expected = new List<HealthCheckResponseModel>
{
new("Check One", "pass", "all good", null),
new("Check Two", "fail", "broken", "https://example.com")
};
_mediator.Send(Arg.Any<GetAllHealthCheckResultsForApi>(), Arg.Any<CancellationToken>())
.Returns(expected);
List<HealthCheckResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(expected);
}
[Test]
public async Task GetAll_Should_Return_Empty_List_When_None_Exist()
{
_mediator.Send(Arg.Any<GetAllHealthCheckResultsForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
List<HealthCheckResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBeEmpty();
}
}
@@ -0,0 +1,55 @@
using System.Reflection;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class LibrariesControllerTests
{
private LibrariesController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new LibrariesController(Substitute.For<ITelevisionRepository>(), _mediator);
}
[Test]
public void ScanStatus_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(LibrariesController).GetMethod(nameof(LibrariesController.GetScanStatus))
?? throw new AssertionException("Missing action GetScanStatus");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/libraries/scan-status");
attribute.Name.ShouldBe("GetLibraryScanStatus");
}
[Test]
public async Task GetScanStatus_Should_Return_Results_From_Mediator()
{
var expected = new List<LibraryScanStatusResponseModel>
{
new(1, 42.5m),
new(2, 99m)
};
_mediator.Send(Arg.Any<GetLibraryScanStatus>(), Arg.Any<CancellationToken>())
.Returns(expected);
List<LibraryScanStatusResponseModel> result = await _controller.GetScanStatus(CancellationToken.None);
result.ShouldBe(expected);
}
}
@@ -0,0 +1,59 @@
using System.Reflection;
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Domain;
using MediatR;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class MediaSourcesControllerTests
{
private MediaSourcesController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new MediaSourcesController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(MediaSourcesController).GetMethod(nameof(MediaSourcesController.GetAll))
?? throw new AssertionException("Missing action GetAll");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/media-sources");
attribute.Name.ShouldBe("GetMediaSources");
}
[Test]
public async Task GetAll_Should_Return_Results_From_Mediator()
{
var expected = new List<MediaSourceResponseModel>
{
new(
1,
"Local",
"Local",
null,
[new MediaSourceLibraryResponseModel(10, "Movies", LibraryMediaKind.Movies, null, 3)])
};
_mediator.Send(Arg.Any<GetAllMediaSourcesForApi>(), Arg.Any<CancellationToken>())
.Returns(expected);
List<MediaSourceResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(expected);
}
}
@@ -68,6 +68,8 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/playouts", "post", "422")]
[TestCase("/api/playouts/{id}", "delete", "404")]
[TestCase("/api/playouts/{id}", "delete", "422")]
[TestCase("/api/playouts/{id}/items", "get", "404")]
[TestCase("/api/artwork/uploads", "post", "422")]
[TestCase("/api/ffmpeg/profiles/{id}", "get", "404")]
[TestCase("/api/ffmpeg/profiles", "post", "404")]
[TestCase("/api/ffmpeg/profiles", "post", "401")]
@@ -5,6 +5,7 @@ using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Playouts;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using LanguageExt;
using MediatR;
@@ -34,8 +35,12 @@ public class PlayoutControllerTests
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute(nameof(PlayoutController.GetAll), "GET", "/api/playouts");
ShouldHaveActionRoute(nameof(PlayoutController.GetById), "GET", "/api/playouts/{id:int}");
ShouldHaveActionRoute(nameof(PlayoutController.GetItems), "GET", "/api/playouts/{id:int}/items");
ShouldHaveActionRoute(nameof(PlayoutController.GetWarningsCount), "GET", "/api/playouts/warnings/count");
ShouldHaveActionRoute(nameof(PlayoutController.Create), "POST", "/api/playouts");
ShouldHaveActionRoute(nameof(PlayoutController.ResetAll), "POST", "/api/playouts/reset-all");
ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/playouts/{id:int}");
}
@@ -162,6 +167,130 @@ public class PlayoutControllerTests
problem.Title.ShouldBe("Resource not found");
}
[Test]
public async Task GetAll_Should_Project_Paged_List_With_BuildStatus()
{
var buildStatus = new PlayoutBuildStatus
{
LastBuild = new DateTimeOffset(2026, 7, 2, 10, 0, 0, TimeSpan.Zero),
Success = false,
Message = "boom"
};
PlayoutNameViewModel vm = MakePlayout(9) with
{
BuildStatus = buildStatus,
DbDailyRebuildTime = TimeSpan.FromHours(4)
};
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
.Returns(new PagedPlayoutsViewModel(1, [vm]));
PagedPlayoutsResponseModel result = await _controller.GetAll("q", 2, 25, CancellationToken.None);
result.TotalCount.ShouldBe(1);
PlayoutListItemResponseModel item = result.Page.Single();
item.Id.ShouldBe(9);
item.ChannelNumber.ShouldBe("101");
item.ChannelName.ShouldBe("Channel");
item.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic);
item.ScheduleName.ShouldBe("Schedule");
item.DailyRebuildTime.ShouldBe(TimeSpan.FromHours(4));
item.BuildStatus.ShouldNotBeNull();
item.BuildStatus.Success.ShouldBeFalse();
item.BuildStatus.Message.ShouldBe("boom");
item.BuildStatus.LastBuild.ShouldBe(buildStatus.LastBuild);
await _mediator.Received(1).Send(
Arg.Is<GetPagedPlayouts>(q => q.Query == "q" && q.PageNum == 2 && q.PageSize == 25),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetAll_Should_Emit_Null_BuildStatus_When_Absent()
{
PlayoutNameViewModel vm = MakePlayout(9) with { BuildStatus = null };
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
.Returns(new PagedPlayoutsViewModel(1, [vm]));
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
result.Page.Single().BuildStatus.ShouldBeNull();
}
[Test]
public async Task GetItems_Should_Project_Items_And_Null_FillerKind_For_Gaps()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var item = new PlayoutItemViewModel(
"Movie",
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
"1:00:00",
string.Empty,
Some(FillerKind.MidRoll));
var gap = new PlayoutItemViewModel(
"UNSCHEDULED",
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 30, 0, TimeSpan.Zero),
"30:00",
string.Empty,
Option<FillerKind>.None);
_mediator.Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>())
.Returns(new PagedPlayoutItemsViewModel(2, [item, gap]));
IActionResult actionResult = await _controller.GetItems(9, showFiller: true, 1, 10, CancellationToken.None);
var result = actionResult.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
result.TotalCount.ShouldBe(2);
result.Page[0].Title.ShouldBe("Movie");
result.Page[0].Duration.ShouldBe("1:00:00");
result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll);
result.Page[1].Title.ShouldBe("UNSCHEDULED");
result.Page[1].FillerKind.ShouldBeNull();
await _mediator.Received(1).Send(
Arg.Is<GetFuturePlayoutItemsById>(q =>
q.PlayoutId == 9 && q.ShowFiller && q.PageNum == 1 && q.PageSize == 10),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetItems_Should_Return_404_For_Unknown_Playout_With_ProblemDetails()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.GetItems(404, showFiller: false, 0, 100, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(404);
problem.Title.ShouldBe("Resource not found");
await _mediator.DidNotReceive().Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task GetWarningsCount_Should_Return_Count()
{
_mediator.Send(Arg.Any<GetPlayoutWarningsCount>(), Arg.Any<CancellationToken>())
.Returns(7);
int result = await _controller.GetWarningsCount(CancellationToken.None);
result.ShouldBe(7);
}
[Test]
public async Task ResetAll_Should_Return_202_And_Send_Command()
{
IActionResult result = await _controller.ResetAll(CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>();
await _mediator.Received(1).Send(Arg.Any<ResetAllPlayouts>(), Arg.Any<CancellationToken>());
}
private static PlayoutNameViewModel MakePlayout(int id) =>
new(
id,
@@ -183,7 +312,13 @@ public class PlayoutControllerTests
vm.PlayoutMode,
vm.ScheduleName,
vm.ScheduleFile,
vm.DbDailyRebuildTime);
vm.DbDailyRebuildTime,
vm.BuildStatus is null
? null
: new PlayoutBuildStatusResponseModel(
vm.BuildStatus.LastBuild,
vm.BuildStatus.Success,
vm.BuildStatus.Message));
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
@@ -0,0 +1,63 @@
using System.Reflection;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.Watermarks;
using MediatR;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class WatermarkControllerTests
{
private WatermarkController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new WatermarkController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(WatermarkController).GetMethod(nameof(WatermarkController.GetAll))
?? throw new AssertionException("Missing action GetAll");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/watermarks");
}
[Test]
public async Task GetAll_Should_Return_Watermarks()
{
List<WatermarkResponseModel> models =
[
new WatermarkResponseModel(1, "Corner Logo"),
new WatermarkResponseModel(2, "Ticker")
];
_mediator.Send(Arg.Any<GetAllWatermarksForApi>(), Arg.Any<CancellationToken>())
.Returns(models);
List<WatermarkResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(models);
}
[Test]
public async Task GetAll_Should_Return_Empty_List_When_None_Exist()
{
_mediator.Send(Arg.Any<GetAllWatermarksForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
List<WatermarkResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBeEmpty();
}
}
@@ -0,0 +1,85 @@
using System.ComponentModel;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ArtworkUploadController(IMediator mediator) : ControllerBase
{
[HttpPost("/api/artwork/uploads", Name = "UploadArtwork")]
[Consumes("multipart/form-data")]
[Tags("Artwork")]
[EndpointSummary("Upload channel logo or watermark artwork")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ArtworkUploadResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Upload(
IFormFile file,
[FromForm] [Description("Artwork target: 'logo' (default) or 'watermark'")] string target,
CancellationToken cancellationToken)
{
if (file is null || file.Length == 0)
{
return BaseError.New("A non-empty image file is required").ToErrorResult();
}
long maxBytes = (long)SystemEnvironment.MaximumUploadMb * 1024 * 1024;
if (file.Length > maxBytes)
{
return BaseError.New($"Image exceeds the maximum allowed size of {SystemEnvironment.MaximumUploadMb} MB")
.ToErrorResult();
}
if (!TryParseTarget(target, out ArtworkKind artworkKind))
{
return BaseError.New($"Unknown upload target '{target}'; expected 'logo' or 'watermark'").ToErrorResult();
}
await using Stream stream = file.OpenReadStream();
Either<BaseError, ArtworkUploadResponseModel> result = await mediator.Send(
new UploadArtwork(stream, file.ContentType, artworkKind),
cancellationToken);
return result.ToCreatedResult(
value => LocationFor(artworkKind, value.Path, value.ContentType),
value => value);
}
// "logo" (default) and "watermark" are the two channel-artwork surfaces the API exposes today.
private static bool TryParseTarget(string target, out ArtworkKind artworkKind)
{
switch ((target ?? string.Empty).Trim().ToLowerInvariant())
{
case "":
case "logo":
artworkKind = ArtworkKind.Logo;
return true;
case "watermark":
artworkKind = ArtworkKind.Watermark;
return true;
default:
artworkKind = ArtworkKind.Logo;
return false;
}
}
// Both GetImage (IptvController) and GetWatermark (ArtworkController) require a contentType
// query param to serve the cached file, so the Location header must carry it too.
private static string LocationFor(ArtworkKind artworkKind, string path, string contentType)
{
string encodedContentType = Uri.EscapeDataString(contentType);
return artworkKind switch
{
// logo paths already carry the servable prefix ("iptv/logos/{file}")
ArtworkKind.Logo => $"/{path}?contentType={encodedContentType}",
_ => $"/artwork/watermarks/{path}?contentType={encodedContentType}"
};
}
}
+28 -3
View File
@@ -6,6 +6,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Extensions;
using MediatR;
@@ -90,18 +91,42 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
[EndpointDescription(
"When mode is omitted, classic playouts use Refresh (rebuild while maintaining collection " +
"progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. " +
"Pass mode to force a specific PlayoutBuildMode.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> ResetPlayout(string channelNumber)
public async Task<IActionResult> ResetPlayout(
string channelNumber,
[FromQuery] PlayoutBuildMode? mode,
CancellationToken cancellationToken)
{
Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
Option<int> maybePlayoutId =
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
foreach (int playoutId in maybePlayoutId)
{
await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset));
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
return new OkResult();
}
return ApiResults.NotFoundProblem();
}
// Match Blazor's Playouts.razor reset semantics: classic playouts refresh (preserve progress),
// every other kind resets from scratch.
private async Task<PlayoutBuildMode> DefaultResetMode(int playoutId, CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> maybePlayout =
await mediator.Send(new GetPlayoutById(playoutId), cancellationToken);
return maybePlayout.Match(
Some: vm => vm.ScheduleKind switch
{
PlayoutScheduleKind.Classic => PlayoutBuildMode.Refresh,
_ => PlayoutBuildMode.Reset
},
None: () => PlayoutBuildMode.Reset);
}
}
@@ -0,0 +1,19 @@
using ErsatzTV.Application.Filler;
using ErsatzTV.Core.Api.Filler;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class FillerPresetController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/filler-presets", Name = "GetFillerPresets")]
[Tags("Filler Presets")]
[EndpointSummary("Get all filler presets")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<FillerPresetResponseModel>), StatusCodes.Status200OK)]
public async Task<List<FillerPresetResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken);
}
@@ -0,0 +1,19 @@
using ErsatzTV.Application.Graphics;
using ErsatzTV.Core.Api.Graphics;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class GraphicsElementController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/graphics-elements", Name = "GetGraphicsElements")]
[Tags("Graphics Elements")]
[EndpointSummary("Get all graphics elements")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<GraphicsElementResponseModel>), StatusCodes.Status200OK)]
public async Task<List<GraphicsElementResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken);
}
@@ -0,0 +1,18 @@
using ErsatzTV.Application.Health;
using ErsatzTV.Core.Api.Health;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class HealthController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/health", Name = "GetHealthChecks")]
[Tags("Health")]
[EndpointSummary("Get health check results")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<HealthCheckResponseModel>), StatusCodes.Status200OK)]
public async Task<List<HealthCheckResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllHealthCheckResultsForApi(), cancellationToken);
}
@@ -1,14 +1,23 @@
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator)
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) : ControllerBase
{
[HttpGet("/api/libraries/scan-status", Name = "GetLibraryScanStatus")]
[Tags("Libraries")]
[EndpointSummary("Get active library scan status")]
[ProducesResponseType(typeof(List<LibraryScanStatusResponseModel>), StatusCodes.Status200OK)]
public async Task<List<LibraryScanStatusResponseModel>> GetScanStatus(CancellationToken cancellationToken) =>
await mediator.Send(new GetLibraryScanStatus(), cancellationToken);
[HttpPost("/api/libraries/{id:int}/scan")]
[Tags("Libraries")]
[EndpointSummary("Scan library")]
@@ -0,0 +1,19 @@
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core.Api.MediaSources;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class MediaSourcesController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/media-sources", Name = "GetMediaSources")]
[Tags("Media Sources")]
[EndpointSummary("Get all media sources with their libraries")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<MediaSourceResponseModel>), StatusCodes.Status200OK)]
public async Task<List<MediaSourceResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllMediaSourcesForApi(), cancellationToken);
}
+95 -1
View File
@@ -3,6 +3,8 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Playouts;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -13,6 +15,32 @@ namespace ErsatzTV.Controllers.Api;
[ApiController]
public class PlayoutController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/playouts", Name = "GetPlayouts")]
[Tags("Playouts")]
[EndpointSummary("List playouts")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedPlayoutsResponseModel), StatusCodes.Status200OK)]
public async Task<PagedPlayoutsResponseModel> GetAll(
[FromQuery] string query = "",
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
CancellationToken cancellationToken = default)
{
PagedPlayoutsViewModel result =
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
return new PagedPlayoutsResponseModel(
result.TotalCount,
result.Page.Map(ToListItemResponse).ToList());
}
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
[Tags("Playouts")]
[EndpointSummary("Count playouts with a failed build")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(int), StatusCodes.Status200OK)]
public async Task<int> GetWarningsCount(CancellationToken cancellationToken) =>
await mediator.Send(new GetPlayoutWarningsCount(), cancellationToken);
[HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")]
[Tags("Playouts")]
[EndpointSummary("Get a playout by id")]
@@ -25,6 +53,34 @@ public class PlayoutController(IMediator mediator) : ControllerBase
return result.Map(ToResponse).ToGetResult();
}
[HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")]
[Tags("Playouts")]
[EndpointSummary("Get upcoming items (and unscheduled gaps) for a playout")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedPlayoutItemsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItems(
int id,
[FromQuery] bool showFiller = false,
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
CancellationToken cancellationToken = default)
{
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
PagedPlayoutItemsViewModel result = await mediator.Send(
new GetFuturePlayoutItemsById(id, showFiller, pageNum, pageSize),
cancellationToken);
return new OkObjectResult(
new PagedPlayoutItemsResponseModel(
result.TotalCount,
result.Page.Map(ToItemResponse).ToList()));
}
[HttpPost("/api/playouts")]
[Tags("Playouts")]
[EndpointSummary("Create a classic playout")]
@@ -49,6 +105,17 @@ public class PlayoutController(IMediator mediator) : ControllerBase
});
}
[HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")]
[Tags("Playouts")]
[EndpointSummary("Reset all playouts")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
{
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
return Accepted();
}
[HttpDelete("/api/playouts/{id:int}")]
[Tags("Playouts")]
[EndpointSummary("Delete a playout")]
@@ -71,5 +138,32 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.PlayoutMode,
vm.ScheduleName,
vm.ScheduleFile,
vm.DbDailyRebuildTime);
vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus));
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) =>
new(
vm.PlayoutId,
vm.ChannelNumber,
vm.ChannelName,
vm.ScheduleKind,
vm.ScheduleName,
vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus));
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
buildStatus is null
? null
: new PlayoutBuildStatusResponseModel(
buildStatus.LastBuild,
buildStatus.Success,
buildStatus.Message);
private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) =>
new(
vm.Title,
vm.Start,
vm.Finish,
vm.Duration,
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null));
}
@@ -0,0 +1,19 @@
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Api.Watermarks;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class WatermarkController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/watermarks", Name = "GetWatermarks")]
[Tags("Watermarks")]
[EndpointSummary("Get all watermarks")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<WatermarkResponseModel>), StatusCodes.Status200OK)]
public async Task<List<WatermarkResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllWatermarksForApi(), cancellationToken);
}
File diff suppressed because it is too large Load Diff