Compare commits

..
Author SHA1 Message Date
timothyandClaude Fable 5 14d06a1e49 docs: note expression-based counts yield null duration estimate
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 2m51s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m22s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review finding on #111: MultipleMode.Count supports
expressions the estimator cannot evaluate; document that they
produce a null (unknown) estimate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:41:57 +02:00
timothy 4b4e2b2f82 feat(api): add schedule item duration estimates
refs #111
2026-07-03 23:41:57 +02:00
timothyandClaude Fable 5 d0652d4adb 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 23:41:57 +02:00
timothy daf94f73f8 Merge pull request 'feat(api): artwork upload endpoint (#104)' (#117) from feat/104-artwork-upload into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m28s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m29s
2026-07-03 21:41:48 +00:00
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
timothy a2d2f5930c chore: stop tracking local MCP config
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 2m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m50s
2026-07-01 19:59:18 +02:00
timothy 45ade1fd78 Merge pull request #56 from issue9-ffmpeg8
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m13s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
fix(ffmpeg): align transcode pipelines with ffmpeg 8
2026-07-01 17:51:13 +00:00
timothy e170b2dc3c fix(ffmpeg): align transcode pipelines with ffmpeg 8
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #9
2026-07-01 19:37:51 +02:00
timothy 183f6c5d2a Merge pull request #55 from issue42-jellyfin-musicvideos
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m15s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m58s
feat(jellyfin): sync MusicVideo libraries
2026-06-30 20:54:20 +00:00
timothy 5560790bcb feat(jellyfin): sync music video libraries refs #42
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m4s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-06-30 19:33:06 +02:00
timothy f25cfdb593 feat(api): default-deny mutating API writes
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m55s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m52s
feat(api): default-deny mutating API writes

Refs #43
2026-06-30 16:55:31 +00:00
timothy 527332a3ac feat(api): default-deny mutating API writes
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #43
2026-06-30 18:49:35 +02:00
timothy cd20dfb2aa Merge pull request 'test: make channel logo generator testable' (#53) from feature/39-channel-logo-generator-testable into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m52s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m33s
Merge pull request #53 from feature/39-channel-logo-generator-testable

Test channel logo generator path injection
2026-06-30 16:20:48 +00:00
timothy 8e6379a51b test: make channel logo generator testable
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #39
2026-06-30 18:14:23 +02:00
timothy 4aec234e20 feat(api): standardize FFmpeg profile endpoints
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m19s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m43s
Merge PR #52: feat(api): standardize FFmpeg profile endpoints

Refs #38
2026-06-30 05:42:06 +00:00
timothy 861827dcbc feat(api): standardize ffmpeg profile endpoints
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m13s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #38
2026-06-30 07:29:47 +02:00
timothy 30ecd9a8a7 Merge pull request 'feat(api): add playout REST endpoints' (#51) from issue37-rest-api-playouts into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m58s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m47s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m58s
Merge pull request #51: feat(api): add playout REST endpoints
2026-06-29 18:37:26 +00:00
timothy b53b06c615 feat(api): add playout REST endpoints
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fixes #37
2026-06-29 20:31:18 +02:00
timothy 4f8b3042db feat(api): add schedule REST endpoints
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m13s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m23s
Merge pull request 'feat(api): add schedule REST endpoints' (#50) from feature/36-rest-api-schedules into main
2026-06-29 16:17:40 +00:00
timothy b552905799 feat(api): add schedule REST endpoints
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m40s
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
Refs #36
2026-06-29 08:13:14 +02:00
timothy 0d101e79b1 Merge pull request 'fix(api): standardize error response contract' (#47) from issue46-rest-api-error-contract into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m54s
Renovate / Renovate (push) Successful in 49s
Dependency vulnerability scan / NuGet vulnerable packages (push) Successful in 41s
fix(api): standardize error response contract

Refs #46
2026-06-28 14:37:54 +00:00
130 changed files with 9752 additions and 413 deletions
+3
View File
@@ -6,6 +6,9 @@ project.lock.json
# Claude Code
.mcp/
.mcp.json
.agents/
plugins/
nupkg/
# Visual Studio Code
-56
View File
@@ -1,56 +0,0 @@
{
"mcpServers": {
"docker-mcp": {
"command": "uvx",
"args": [
"mcp-server-docker"
],
"env": {
"DOCKER_HOST": "ssh://timothy@192.168.1.99"
}
},
"ssh-mcp": {
"command": "npx",
"args": [
"-y",
"ssh-mcp",
"--",
"--host=192.168.1.99",
"--user=timothy"
],
"env": {}
},
"gitea": {
"command": "gitea-mcp-server",
"args": [
"-t", "stdio",
"-host", "http://192.168.1.95:3000",
"-token", "8341af0733ab9ce084ea7adf38b76aa9ebc3bd67"
],
"env": {}
},
"csharp-lsp": {
"command": "/usr/local/share/dotnet/dotnet",
"args": [
"run",
"--project", "/Users/timothy/ersatztv/.mcp/csharp-lsp-mcp/csharp-lsp-mcp/src/CSharpLspMcp",
"-c", "Release"
],
"env": {
"PATH": "/usr/local/share/dotnet:/Users/timothy/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin"
}
},
"nuget": {
"command": "/usr/local/share/dotnet/dotnet",
"args": [
"dnx",
"NuGet.Mcp.Server",
"--source", "https://api.nuget.org/v3/index.json",
"--yes"
],
"env": {
"DOTNET_ROOT": "/usr/local/share/dotnet"
}
}
}
}
+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
};
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -24,8 +25,15 @@ public class CreateFFmpegProfileHandler :
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
Option<int> maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken);
return await maybeResolutionId.Match(
Some: async resolutionId =>
{
Validation<BaseError, FFmpegProfile> validation = Validate(request, resolutionId);
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
},
None: () => Task.FromResult<Either<BaseError, CreateFFmpegProfileResult>>(
new NotFoundError($"[Resolution] {request.ResolutionId} does not exist")));
}
private async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
@@ -38,13 +46,11 @@ public class CreateFFmpegProfileHandler :
return new CreateFFmpegProfileResult(ffmpegProfile.Id);
}
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
TvContext dbContext,
private static Validation<BaseError, FFmpegProfile> Validate(
CreateFFmpegProfile request,
CancellationToken cancellationToken) =>
(ValidateName(request), ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request, cancellationToken))
.Apply((name, threadCount, resolutionId) =>
int resolutionId) =>
(ValidateName(request), ValidateThreadCount(request))
.Apply((name, threadCount) =>
{
var hwAccel = request.NormalizeVideo
? request.HardwareAcceleration
@@ -110,12 +116,11 @@ public class CreateFFmpegProfileHandler :
private static Validation<BaseError, int> ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) =>
createFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private static Task<Validation<BaseError, int>> ResolutionMustExist(
private static Task<Option<int>> ResolutionMustExist(
TvContext dbContext,
CreateFFmpegProfile createFFmpegProfile,
CancellationToken cancellationToken) =>
dbContext.Resolutions
.SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId, cancellationToken)
.MapT(r => r.Id)
.Map(o => o.ToValidation<BaseError>($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist"));
.MapT(r => r.Id);
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
@@ -19,8 +20,19 @@ public class DeleteFFmpegProfileHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => DoDeletion(dbContext, p));
Option<FFmpegProfile> maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
return await maybeProfile.Match(
Some: async profile =>
{
Validation<BaseError, FFmpegProfile> validation = await Validate(
dbContext,
request,
profile,
cancellationToken);
return await validation.Apply(p => DoDeletion(dbContext, p));
},
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"FFmpegProfile {request.FFmpegProfileId} does not exist")));
}
private async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
@@ -34,19 +46,18 @@ public class DeleteFFmpegProfileHandler(
private async Task<Validation<BaseError, FFmpegProfile>> Validate(
TvContext dbContext,
DeleteFFmpegProfile request,
FFmpegProfile profile,
CancellationToken cancellationToken) =>
(await FFmpegProfileMustNotBeUsed(dbContext, request, cancellationToken),
await FFmpegProfileMustNotBeDefault(request, cancellationToken),
await FFmpegProfileMustExist(dbContext, request, cancellationToken))
.Apply((_, _, ffmpegProfile) => ffmpegProfile);
await FFmpegProfileMustNotBeDefault(request, cancellationToken))
.Apply((_, _) => profile);
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext,
DeleteFFmpegProfile request,
CancellationToken cancellationToken) =>
dbContext.FFmpegProfiles
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken);
private static async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeUsed(
TvContext dbContext,
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg.Preset;
@@ -17,8 +18,22 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
Option<FFmpegProfile> maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
return await maybeProfile.Match(
Some: async profile =>
{
Option<int> maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken);
return await maybeResolutionId.Match(
Some: async _ =>
{
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, profile);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, UpdateFFmpegProfileResult>>(
new NotFoundError($"[Resolution] {request.ResolutionId} does not exist")));
},
None: () => Task.FromResult<Either<BaseError, UpdateFFmpegProfileResult>>(
new NotFoundError("FFmpegProfile does not exist.")));
}
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
@@ -109,20 +124,16 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
TvContext dbContext,
UpdateFFmpegProfile request,
CancellationToken cancellationToken) =>
(await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await ValidateName(dbContext, request),
ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request, cancellationToken))
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
FFmpegProfile profile) =>
(await ValidateName(dbContext, request), ValidateThreadCount(request))
.Apply((_, _) => profile);
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile,
CancellationToken cancellationToken) =>
dbContext.FFmpegProfiles
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken);
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
@@ -147,12 +158,11 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private static Task<Validation<BaseError, int>> ResolutionMustExist(
private static Task<Option<int>> ResolutionMustExist(
TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile,
CancellationToken cancellationToken) =>
dbContext.Resolutions
.SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId, cancellationToken)
.MapT(r => r.Id)
.Map(o => o.ToValidation<BaseError>($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist"));
.MapT(r => r.Id);
}
+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);
}
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -28,8 +29,23 @@ public class CreateClassicPlayoutHandler : IRequestHandler<CreateClassicPlayout,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
Option<Channel> maybeChannel = await GetChannel(dbContext, request, cancellationToken);
return await maybeChannel.Match(
Some: async channel =>
{
Option<ProgramSchedule> maybeProgramSchedule =
await GetProgramSchedule(dbContext, request, cancellationToken);
return await maybeProgramSchedule.Match(
Some: async programSchedule =>
{
Validation<BaseError, Playout> validation = Validate(request, channel, programSchedule);
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
},
None: () => Task.FromResult<Either<BaseError, CreatePlayoutResponse>>(
new NotFoundError("Program schedule does not exist")));
},
None: () => Task.FromResult<Either<BaseError, CreatePlayoutResponse>>(
new NotFoundError("Channel does not exist")));
}
private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
@@ -46,12 +62,12 @@ public class CreateClassicPlayoutHandler : IRequestHandler<CreateClassicPlayout,
return new CreatePlayoutResponse(playout.Id);
}
private static async Task<Validation<BaseError, Playout>> Validate(
TvContext dbContext,
private static Validation<BaseError, Playout> Validate(
CreateClassicPlayout request,
CancellationToken cancellationToken) =>
(await ValidateChannel(dbContext, request, cancellationToken),
await ValidateProgramSchedule(dbContext, request, cancellationToken),
Channel channel,
ProgramSchedule programSchedule) =>
(ChannelMustNotHavePlayouts(channel),
ProgramScheduleMustHaveItems(programSchedule),
ValidateScheduleKind(request))
.Apply((channel, programSchedule, scheduleKind) => new Playout
{
@@ -60,15 +76,13 @@ public class CreateClassicPlayoutHandler : IRequestHandler<CreateClassicPlayout,
ScheduleKind = scheduleKind
});
private static Task<Validation<BaseError, Channel>> ValidateChannel(
private static Task<Option<Channel>> GetChannel(
TvContext dbContext,
CreateClassicPlayout createClassicPlayout,
CancellationToken cancellationToken) =>
dbContext.Channels
.Include(c => c.Playouts)
.SelectOneAsync(c => c.Id, c => c.Id == createClassicPlayout.ChannelId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Channel does not exist"))
.BindT(ChannelMustNotHavePlayouts);
.SelectOneAsync(c => c.Id, c => c.Id == createClassicPlayout.ChannelId, cancellationToken);
private static Validation<BaseError, Channel> ChannelMustNotHavePlayouts(Channel channel) =>
Optional(channel.Playouts.Count)
@@ -76,15 +90,13 @@ public class CreateClassicPlayoutHandler : IRequestHandler<CreateClassicPlayout,
.Map(_ => channel)
.ToValidation<BaseError>("Channel already has one playout");
private static Task<Validation<BaseError, ProgramSchedule>> ValidateProgramSchedule(
private static Task<Option<ProgramSchedule>> GetProgramSchedule(
TvContext dbContext,
CreateClassicPlayout createClassicPlayout,
CancellationToken cancellationToken) =>
dbContext.ProgramSchedules
.Include(ps => ps.Items)
.SelectOneAsync(ps => ps.Id, ps => ps.Id == createClassicPlayout.ProgramScheduleId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Program schedule does not exist"))
.BindT(ProgramScheduleMustHaveItems);
.SelectOneAsync(ps => ps.Id, ps => ps.Id == createClassicPlayout.ProgramScheduleId, cancellationToken);
private static Validation<BaseError, ProgramSchedule> ProgramScheduleMustHaveItems(
ProgramSchedule programSchedule) =>
@@ -3,6 +3,7 @@ using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Notifications;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -45,6 +46,6 @@ public class DeletePlayoutHandler(
return maybePlayout
.Map(_ => Unit.Default)
.ToEither(BaseError.New($"Playout {request.PlayoutId} does not exist."));
.ToEither<BaseError>(new NotFoundError($"Playout {request.PlayoutId} does not exist."));
}
}
@@ -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,
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -28,8 +29,16 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(ps => PersistItem(dbContext, request, ps, cancellationToken));
Option<ProgramSchedule> maybeProgramSchedule =
await ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken);
return await maybeProgramSchedule.Match(
Some: async programSchedule =>
{
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule);
return await validation.Apply(ps => PersistItem(dbContext, request, ps, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, ProgramScheduleItemViewModel>>(
new NotFoundError("[ProgramScheduleId] does not exist.")));
}
private async Task<ProgramScheduleItemViewModel> PersistItem(
@@ -54,10 +63,25 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
return ProjectToViewModel(item);
}
private static Task<Validation<BaseError, ProgramSchedule>> Validate(
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
TvContext dbContext,
AddProgramScheduleItem request,
CancellationToken cancellationToken) =>
ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken)
.BindT(programSchedule => PlayoutModeMustBeValid(request, programSchedule));
ProgramSchedule programSchedule)
{
Validation<BaseError, ProgramSchedule> validation =
PlayoutModeMustBeValid(request, programSchedule)
.Bind(programSchedule => CollectionTypeMustBeValid(request, programSchedule));
return await validation.ToEither().Match(
Left: error => Task.FromResult<Validation<BaseError, ProgramSchedule>>(
Fail<BaseError, ProgramSchedule>(error)),
Right: async validProgramSchedule =>
{
Either<BaseError, ProgramSchedule> fillerResult = await FillerConfigurationMustBeValid(
dbContext,
request,
validProgramSchedule);
return fillerResult.ToValidation();
});
}
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -18,11 +19,14 @@ public class DeleteProgramScheduleHandler : IRequestHandler<DeleteProgramSchedul
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await ProgramScheduleMustExist(
Option<ProgramSchedule> maybeProgramSchedule = await ProgramScheduleMustExist(
dbContext,
request,
cancellationToken);
return await validation.Apply(ps => DoDeletion(dbContext, ps));
return await maybeProgramSchedule.Match(
Some: programSchedule => DoDeletion(dbContext, programSchedule).Map(Right<BaseError, Unit>),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"ProgramSchedule {request.ProgramScheduleId} does not exist.")));
}
private static Task<Unit> DoDeletion(TvContext dbContext, ProgramSchedule programSchedule)
@@ -31,11 +35,11 @@ public class DeleteProgramScheduleHandler : IRequestHandler<DeleteProgramSchedul
return dbContext.SaveChangesAsync().ToUnit();
}
private static Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
private static Task<Option<ProgramSchedule>> ProgramScheduleMustExist(
TvContext dbContext,
DeleteProgramSchedule request,
CancellationToken cancellationToken) =>
dbContext.ProgramSchedules
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.ProgramScheduleId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"ProgramSchedule {request.ProgramScheduleId} does not exist."));
.Map(identity);
}
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.ProgramSchedules;
public record DeleteProgramScheduleItem(int ProgramScheduleId, int ProgramScheduleItemId)
: IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,46 @@
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ProgramSchedules;
public class DeleteProgramScheduleItemHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> channel) : IRequestHandler<DeleteProgramScheduleItem, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
DeleteProgramScheduleItem request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
ProgramScheduleItem item = await dbContext.ProgramScheduleItems
.Include(i => i.ProgramSchedule)
.ThenInclude(ps => ps.Playouts)
.SingleOrDefaultAsync(
i => i.Id == request.ProgramScheduleItemId && i.ProgramScheduleId == request.ProgramScheduleId,
cancellationToken);
if (item is null)
{
return new NotFoundError(
$"ProgramScheduleItem {request.ProgramScheduleItemId} does not exist on schedule {request.ProgramScheduleId}.");
}
List<Playout> playouts = item.ProgramSchedule.Playouts;
dbContext.ProgramScheduleItems.Remove(item);
await dbContext.SaveChangesAsync(cancellationToken);
foreach (Playout playout in playouts)
{
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
}
return Unit.Default;
}
}
@@ -1,6 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -9,7 +10,7 @@ namespace ErsatzTV.Application.ProgramSchedules;
public abstract class ProgramScheduleItemCommandBase
{
protected static Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
protected static Task<Option<ProgramSchedule>> ProgramScheduleMustExist(
TvContext dbContext,
int programScheduleId,
CancellationToken cancellationToken) =>
@@ -17,7 +18,7 @@ public abstract class ProgramScheduleItemCommandBase
.Include(ps => ps.Items)
.Include(ps => ps.Playouts)
.SelectOneAsync(ps => ps.Id, ps => ps.Id == programScheduleId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("[ProgramScheduleId] does not exist."));
.Map(identity);
protected static async Task<Either<BaseError, ProgramSchedule>> FillerConfigurationMustBeValid(
TvContext dbContext,
@@ -352,9 +353,11 @@ public abstract class ProgramScheduleItemCommandBase
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
};
result.ProgramScheduleItemWatermarks = [];
result.ProgramScheduleItemGraphicsElements = [];
foreach (int watermarkId in item.WatermarkIds)
{
result.ProgramScheduleItemWatermarks ??= [];
result.ProgramScheduleItemWatermarks.Add(
new ProgramScheduleItemWatermark
{
@@ -365,7 +368,6 @@ public abstract class ProgramScheduleItemCommandBase
foreach (int graphicsElementId in item.GraphicsElementIds)
{
result.ProgramScheduleItemGraphicsElements ??= [];
result.ProgramScheduleItemGraphicsElements.Add(
new ProgramScheduleItemGraphicsElement
{
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -19,8 +20,16 @@ public class ReplaceProgramScheduleItemsHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(ps => PersistItems(dbContext, request, ps, cancellationToken));
Option<ProgramSchedule> maybeProgramSchedule =
await ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken);
return await maybeProgramSchedule.Match(
Some: async programSchedule =>
{
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule);
return await validation.Apply(ps => PersistItems(dbContext, request, ps, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>(
new NotFoundError("[ProgramScheduleId] does not exist.")));
}
private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems(
@@ -50,15 +59,20 @@ public class ReplaceProgramScheduleItemsHandler(
return programSchedule.Items.Map(ProjectToViewModel);
}
private static Task<Validation<BaseError, ProgramSchedule>> Validate(
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
TvContext dbContext,
ReplaceProgramScheduleItems request,
CancellationToken cancellationToken) =>
ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken)
.BindT(programSchedule => PlayoutModesMustBeValid(request, programSchedule))
.BindT(programSchedule => CollectionTypesMustBeValid(request, programSchedule))
.BindT(programSchedule => PlaybackOrdersMustBeValid(request, programSchedule))
.BindT(programSchedule => FillerConfigurationsMustBeValid(dbContext, request, programSchedule));
ProgramSchedule programSchedule)
{
Validation<BaseError, ProgramSchedule> validation = PlayoutModesMustBeValid(request, programSchedule)
.Bind(programSchedule => CollectionTypesMustBeValid(request, programSchedule))
.Bind(programSchedule => PlaybackOrdersMustBeValid(request, programSchedule));
return await validation.ToEither().Match(
Left: error => Task.FromResult<Validation<BaseError, ProgramSchedule>>(
Fail<BaseError, ProgramSchedule>(error)),
Right: validProgramSchedule => FillerConfigurationsMustBeValid(dbContext, request, validProgramSchedule));
}
private static Validation<BaseError, ProgramSchedule> PlayoutModesMustBeValid(
ReplaceProgramScheduleItems request,
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -20,8 +21,16 @@ public class UpdateProgramScheduleHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
Option<ProgramSchedule> maybeProgramSchedule =
await ProgramScheduleMustExist(dbContext, request, cancellationToken);
return await maybeProgramSchedule.Match(
Some: async programSchedule =>
{
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule, cancellationToken);
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
},
None: () => Task.FromResult<Either<BaseError, UpdateProgramScheduleResult>>(
new NotFoundError("Schedule does not exist")));
}
private async Task<UpdateProgramScheduleResult> ApplyUpdateRequest(
@@ -66,17 +75,17 @@ public class UpdateProgramScheduleHandler(
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
TvContext dbContext,
UpdateProgramSchedule request,
ProgramSchedule programSchedule,
CancellationToken cancellationToken) =>
(await ProgramScheduleMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request, cancellationToken))
.Apply((programSchedule, _) => programSchedule);
(await ValidateName(dbContext, request, cancellationToken)).Map(_ => programSchedule);
private static Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
private static Task<Option<ProgramSchedule>> ProgramScheduleMustExist(
TvContext dbContext,
UpdateProgramSchedule request,
CancellationToken cancellationToken) =>
dbContext.ProgramSchedules
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.ProgramScheduleId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Schedule does not exist"));
.Map(identity);
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
@@ -44,6 +44,33 @@ public abstract record ProgramScheduleItemViewModel(
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode)
{
/// <summary>
/// A rough estimate, in wall-clock time, of how long a single pass of this schedule item will play,
/// derived from the aggregated playout runtimes of the referenced content.
/// <para>
/// Semantics by <see cref="PlayoutMode" />:
/// <list type="bullet">
/// <item><b>One</b> — the average runtime of one item in the referenced collection.</item>
/// <item>
/// <b>Multiple</b> — the average item runtime multiplied by the configured count
/// (<see cref="MultipleMode.Count" />), or the whole collection runtime for
/// <see cref="MultipleMode.CollectionSize" />. An expression-based (non-integer)
/// count cannot be evaluated here and yields <c>null</c>.
/// </item>
/// <item><b>Flood</b> — always <c>null</c>: a flood item fills the remaining time and is unbounded.</item>
/// <item><b>Duration</b> — the explicit <c>playoutDuration</c> setting on the item.</item>
/// </list>
/// </para>
/// <para>
/// <c>null</c> whenever a bounded estimate cannot be produced — an unbounded mode (Flood),
/// a referenced collection with no items that have a known positive duration, a Multiple mode other
/// than Count/CollectionSize, or a collection type other than <see cref="CollectionType.Collection" />
/// (smart/multi/playlist/search/rerun/show/season/artist references are not aggregated in this pass).
/// Callers should treat a <c>null</c> as "unknown", never as zero.
/// </para>
/// </summary>
public TimeSpan? DurationEstimate { get; init; }
public string Name => CollectionType switch
{
CollectionType.Collection => Collection?.Name,
@@ -0,0 +1,18 @@
namespace ErsatzTV.Application.ProgramSchedules;
/// <summary>
/// The items of a schedule together with computed runtime estimates. Each item carries its own
/// <see cref="ProgramScheduleItemViewModel.DurationEstimate" /> (nullable — see that property for
/// the per-mode semantics), and <see cref="TotalDurationEstimate" /> is the sum of the items that
/// could be estimated.
/// </summary>
/// <param name="Items">The schedule items, each with a nullable <c>DurationEstimate</c>.</param>
/// <param name="TotalDurationEstimate">
/// The sum of every non-null per-item estimate, i.e. a rough runtime for a single pass through the
/// estimable items. <c>null</c> when no item in the schedule could be estimated (for example a
/// schedule made up entirely of Flood items, or of collection types that are not aggregated).
/// Because unbounded items contribute nothing, this is a lower bound, never an exact schedule length.
/// </param>
public record ProgramScheduleItemsWithDurationViewModel(
List<ProgramScheduleItemViewModel> Items,
TimeSpan? TotalDurationEstimate);
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.ProgramSchedules;
public record GetProgramScheduleItemsWithDurations(int Id)
: IRequest<ProgramScheduleItemsWithDurationViewModel>;
@@ -0,0 +1,70 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.ProgramSchedules.ScheduleItemDurationEstimator;
namespace ErsatzTV.Application.ProgramSchedules;
public class GetProgramScheduleItemsWithDurationsHandler(
IMediator mediator,
IMediaCollectionRepository mediaCollectionRepository)
: IRequestHandler<GetProgramScheduleItemsWithDurations, ProgramScheduleItemsWithDurationViewModel>
{
public async Task<ProgramScheduleItemsWithDurationViewModel> Handle(
GetProgramScheduleItemsWithDurations request,
CancellationToken cancellationToken)
{
List<ProgramScheduleItemViewModel> items =
await mediator.Send(new GetProgramScheduleItems(request.Id), cancellationToken);
Dictionary<int, CollectionDuration> durationsByCollectionId =
await AggregateReferencedCollections(items);
var itemsWithEstimates = items
.Map(item => item with { DurationEstimate = Estimate(item, durationsByCollectionId) })
.ToList();
List<TimeSpan> estimates = itemsWithEstimates
.Map(item => Optional(item.DurationEstimate))
.Somes()
.ToList();
TimeSpan? total = estimates.Count > 0
? TimeSpan.FromTicks(estimates.Sum(estimate => estimate.Ticks))
: null;
return new ProgramScheduleItemsWithDurationViewModel(itemsWithEstimates, total);
}
// Aggregate MediaVersion.Duration once per distinct referenced collection (not per item). Only plain
// collections are resolved here; other reference types are estimated as null (see DurationEstimate docs).
private async Task<Dictionary<int, CollectionDuration>> AggregateReferencedCollections(
IReadOnlyList<ProgramScheduleItemViewModel> items)
{
List<int> collectionIds = items
.Filter(item => item.CollectionType is CollectionType.Collection && item.Collection is not null)
.Filter(item => item.PlayoutMode is PlayoutMode.One or PlayoutMode.Multiple)
.Map(item => item.Collection.Id)
.Distinct()
.ToList();
var result = new Dictionary<int, CollectionDuration>();
foreach (int collectionId in collectionIds)
{
List<MediaItem> mediaItems = await mediaCollectionRepository.GetItems(collectionId);
List<TimeSpan> durations = mediaItems
.Map(mediaItem => mediaItem.GetDurationForPlayout())
.Filter(duration => duration > TimeSpan.Zero)
.ToList();
if (durations.Count > 0)
{
var total = TimeSpan.FromTicks(durations.Sum(duration => duration.Ticks));
result[collectionId] = new CollectionDuration(total, durations.Count);
}
}
return result;
}
}
@@ -0,0 +1,73 @@
using System.Globalization;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
/// <summary>
/// Pure computation of per-item runtime estimates from pre-aggregated collection durations.
/// Kept separate from the query handler so the (non-trivial) per-mode math can be unit-tested
/// without a database. See <see cref="ProgramScheduleItemViewModel.DurationEstimate" /> for the
/// documented semantics this implements.
/// </summary>
internal static class ScheduleItemDurationEstimator
{
/// <summary>
/// Aggregate runtime of a single referenced collection: the total runtime of every item with a
/// known non-zero duration, and how many such items there are.
/// </summary>
public sealed record CollectionDuration(TimeSpan Total, int ItemCount)
{
public TimeSpan? Average => ItemCount > 0 ? Total / ItemCount : null;
}
/// <summary>
/// Estimate the runtime of one pass of <paramref name="item" />, or <c>null</c> when no bounded
/// estimate is possible. <paramref name="durationsByCollectionId" /> holds aggregates only for the
/// plain collections that were resolved; a missing entry yields <c>null</c>.
/// </summary>
public static TimeSpan? Estimate(
ProgramScheduleItemViewModel item,
IReadOnlyDictionary<int, CollectionDuration> durationsByCollectionId)
{
if (item is ProgramScheduleItemDurationViewModel durationItem)
{
return durationItem.PlayoutDuration;
}
// only plain collections are aggregated in this pass
if (item.CollectionType is not CollectionType.Collection || item.Collection is null)
{
return null;
}
if (!durationsByCollectionId.TryGetValue(item.Collection.Id, out CollectionDuration duration))
{
return null;
}
return item switch
{
// one item per pass -> the average item runtime
ProgramScheduleItemOneViewModel => duration.Average,
// a fixed count of items, or the whole collection once
ProgramScheduleItemMultipleViewModel multiple => EstimateMultiple(multiple, duration),
// Flood is an unbounded fill; Duration is handled above from its explicit playoutDuration.
_ => null
};
}
private static TimeSpan? EstimateMultiple(
ProgramScheduleItemMultipleViewModel multiple,
CollectionDuration duration) =>
multiple.MultipleMode switch
{
MultipleMode.Count when
int.TryParse(multiple.Count, NumberStyles.Integer, CultureInfo.InvariantCulture, out int count)
&& count > 0
&& duration.Average is { } average => average * count,
MultipleMode.CollectionSize => duration.Total,
_ => null
};
}
@@ -1,10 +1,14 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Watermarks;
internal static class Mapper
{
internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
new(watermark.Id, watermark.Name);
public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) =>
new(
watermark.Id,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Watermarks;
namespace ErsatzTV.Application.Watermarks;
public record GetAllWatermarksForApi : IRequest<List<WatermarkResponseModel>>;
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Watermarks.Mapper;
namespace ErsatzTV.Application.Watermarks;
public class GetAllWatermarksForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllWatermarksForApi, List<WatermarkResponseModel>>
{
public async Task<List<WatermarkResponseModel>> Handle(
GetAllWatermarksForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<ChannelWatermark> watermarks = await dbContext.ChannelWatermarks
.AsNoTracking()
.ToListAsync(cancellationToken);
return watermarks.Map(ProjectToResponseModel).ToList();
}
}
@@ -32,4 +32,9 @@
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\ErsatzTV\Resources\Fonts\Sen.ttf" Link="Resources\Fonts\Sen.ttf" CopyToOutputDirectory="PreserveNewest" />
<None Include="..\ErsatzTV\wwwroot\images\ersatztv-500.png" Link="Resources\Images\ersatztv-500.png" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,41 @@
using ErsatzTV.Core.Images;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Shouldly;
using SkiaSharp;
namespace ErsatzTV.Core.Tests.Images;
[TestFixture]
public class ChannelLogoGeneratorTests
{
[TestCase("News", 100, 200)]
[TestCase("A Very Long Channel Name That Should Force Font Shrinking", 100, 200)]
[TestCase("Tall Logo", 180, 120)]
public void GenerateChannelLogo_WithInjectedAssets_ReturnsValidPngWithRequestedDimensions(
string text,
int logoHeight,
int logoWidth)
{
var generator = new ChannelLogoGenerator(
NullLogger<ChannelLogoGenerator>.Instance,
TestAssetPath("Resources", "Images", "ersatztv-500.png"),
TestAssetPath("Resources", "Fonts", "Sen.ttf"));
byte[] bytes = generator.GenerateChannelLogo(text, logoHeight, logoWidth, CancellationToken.None)
.Match(
Left: error => throw new AssertionException(error.Value),
Right: identity);
using var stream = new MemoryStream(bytes);
using var codec = SKCodec.Create(stream);
codec.ShouldNotBeNull();
codec.EncodedFormat.ShouldBe(SKEncodedImageFormat.Png);
codec.Info.Width.ShouldBe(logoWidth);
codec.Info.Height.ShouldBe(logoHeight);
}
private static string TestAssetPath(params string[] segments) =>
Path.Combine([TestContext.CurrentContext.TestDirectory, .. segments]);
}
@@ -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);
@@ -0,0 +1,37 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutResponseModel(
int Id,
PlayoutScheduleKind ScheduleKind,
string ChannelName,
string ChannelNumber,
ChannelPlayoutMode PlayoutMode,
string ScheduleName,
string? ScheduleFile,
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus)
{
public static PlayoutResponseModel From(
int id,
PlayoutScheduleKind scheduleKind,
string channelName,
string channelNumber,
ChannelPlayoutMode playoutMode,
string scheduleName,
string? scheduleFile,
TimeSpan? dailyRebuildTime,
PlayoutBuildStatusResponseModel? buildStatus) =>
new(
id,
scheduleKind,
channelName,
channelNumber,
playoutMode,
scheduleName,
scheduleFile,
dailyRebuildTime,
buildStatus);
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Api.Watermarks;
public record WatermarkResponseModel(int Id, string Name);
+23 -5
View File
@@ -10,11 +10,25 @@ public class ChannelLogoGenerator : IChannelLogoGenerator
public const string GetRoute = "/iptv/logos/gen";
public const string GetRouteQueryParamName = "text";
private readonly string _fontPath;
private readonly ILogger _logger;
private readonly string _overlayImagePath;
public ChannelLogoGenerator(
ILogger<ChannelLogoGenerator> logger) =>
ILogger<ChannelLogoGenerator> logger)
: this(logger, DefaultOverlayImagePath(), DefaultFontPath())
{
}
public ChannelLogoGenerator(
ILogger<ChannelLogoGenerator> logger,
string overlayImagePath,
string fontPath)
{
_logger = logger;
_overlayImagePath = overlayImagePath;
_fontPath = fontPath;
}
public Either<BaseError, byte[]> GenerateChannelLogo(
string text,
@@ -29,13 +43,11 @@ public class ChannelLogoGenerator : IChannelLogoGenerator
canvas.Clear(SKColors.Black);
//etv logo
string overlayImagePath = Path.Combine("wwwroot", "images", "ersatztv-500.png");
using var overlayImage = SKBitmap.Decode(overlayImagePath);
using var overlayImage = SKBitmap.Decode(_overlayImagePath);
canvas.DrawBitmap(overlayImage, new SKRect(155, 60, 205, 110));
//Custom Font
string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "Sen.ttf");
using var fontTypeface = SKTypeface.FromFile(fontPath);
using var fontTypeface = SKTypeface.FromFile(_fontPath);
var fontSize = 30;
var font = new SKFont
{
@@ -80,4 +92,10 @@ public class ChannelLogoGenerator : IChannelLogoGenerator
public static string GenerateChannelLogoUrl(Channel channel) =>
$"http://localhost:{Settings.StreamingPort}{GetRoute}?{GetRouteQueryParamName}={channel.WebEncodedName}";
private static string DefaultFontPath() =>
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "Sen.ttf");
private static string DefaultOverlayImagePath() =>
Path.Combine("wwwroot", "images", "ersatztv-500.png");
}
@@ -13,6 +13,11 @@ public interface IJellyfinApiClient
string authorizationHeader,
JellyfinLibrary library);
IAsyncEnumerable<Tuple<MusicVideo, int>> GetMusicVideoLibraryItems(
string address,
string authorizationHeader,
JellyfinLibrary library);
IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItemsWithoutPeople(
string address,
string authorizationHeader,
@@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Jellyfin;
namespace ErsatzTV.Core.Interfaces.Jellyfin;
public interface IJellyfinMusicVideoLibraryScanner
{
Task<Either<BaseError, Unit>> ScanLibrary(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
bool deepScan,
CancellationToken cancellationToken);
}
@@ -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,38 @@
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.FFmpeg.Tests.GlobalOption.HardwareAcceleration;
[TestFixture]
public class QsvHardwareAccelerationOptionTests
{
[Test]
public void GlobalOptions_WithDevice_ShouldDeriveQsvDeviceFromVaapiDevice()
{
var option = new QsvHardwareAccelerationOption("/dev/dri/renderD128", FFmpegCapability.Software);
option.GlobalOptions.ShouldBe(
[
"-init_hw_device", "vaapi=va:/dev/dri/renderD128",
"-init_hw_device", "qsv=hw@va",
"-filter_hw_device", "hw"
]);
}
[Test]
public void GlobalOptions_WithHardwareDecode_ShouldEnableQsvHwaccel()
{
var option = new QsvHardwareAccelerationOption(None, FFmpegCapability.Hardware);
option.GlobalOptions.ShouldBe(
[
"-hwaccel", "qsv",
"-hwaccel_output_format", "qsv",
"-init_hw_device", "qsv=hw",
"-filter_hw_device", "hw"
]);
}
}
@@ -16,8 +16,6 @@ public class QsvHardwareAccelerationOption(Option<string> device, FFmpegCapabili
{
get
{
string[] initDevices = ["-init_hw_device", "qsv=hw", "-filter_hw_device", "hw"];
var result = new List<string>
{
"-hwaccel", "qsv",
@@ -29,18 +27,26 @@ public class QsvHardwareAccelerationOption(Option<string> device, FFmpegCapabili
result.Clear();
}
if (OperatingSystem.IsLinux())
var deviceConfigured = false;
foreach (string qsvDevice in device)
{
foreach (string qsvDevice in device)
if (!string.IsNullOrWhiteSpace(qsvDevice))
{
if (!string.IsNullOrWhiteSpace(qsvDevice))
{
result.AddRange(new[] { "-qsv_device", qsvDevice });
}
result.AddRange(
[
"-init_hw_device", $"vaapi=va:{qsvDevice}",
"-init_hw_device", "qsv=hw@va"
]);
deviceConfigured = true;
}
}
result.AddRange(initDevices);
if (!deviceConfigured)
{
result.AddRange(["-init_hw_device", "qsv=hw"]);
}
result.AddRange(["-filter_hw_device", "hw"]);
return result.ToArray();
}
@@ -0,0 +1,73 @@
using System.Net;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Jellyfin;
using LanguageExt;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Infrastructure.Tests.Jellyfin;
public class JellyfinApiClientTests
{
[TestFixture]
public class GetLibraries
{
[Test]
public async Task Should_Project_MusicVideo_Libraries()
{
const string response = """
[
{
"Name": "Concerts",
"CollectionType": "musicvideos",
"ItemId": "library-1",
"LibraryOptions": {
"PathInfos": []
}
}
]
""";
var client = new JellyfinApiClient(
new MemoryCache(new MemoryCacheOptions()),
Substitute.For<IJellyfinPathReplacementService>(),
Substitute.For<IFallbackMetadataProvider>(),
new SingleResponseHttpClientFactory(response),
Substitute.For<ILogger<JellyfinApiClient>>());
Either<BaseError, List<JellyfinLibrary>> result =
await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
result.IsRight.ShouldBeTrue();
List<JellyfinLibrary> libraries = result.RightToSeq().Single();
libraries.Count.ShouldBe(1);
libraries[0].Name.ShouldBe("Concerts");
libraries[0].ItemId.ShouldBe("library-1");
libraries[0].MediaKind.ShouldBe(LibraryMediaKind.MusicVideos);
libraries[0].ShouldSyncItems.ShouldBeFalse();
libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-1");
}
}
private sealed class SingleResponseHttpClientFactory(string response) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(new SingleResponseHttpMessageHandler(response));
}
private sealed class SingleResponseHttpMessageHandler(string response) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(response)
});
}
}
@@ -42,6 +42,26 @@ public interface IJellyfinApi
[Query]
int limit = 0);
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
Task<JellyfinLibraryItemsResponse> GetMusicVideoLibraryItems(
[Header("Authorization")]
string authorizationHeader,
[Query]
string parentId,
[Query]
string fields =
"Path,Genres,Artists,Tags,DateCreated,Etag,Overview,Studios,People,OfficialRating,ProviderIds,Chapters",
[Query]
string includeItemTypes = "MusicVideo",
[Query]
bool recursive = true,
[Query]
string filters = "IsNotFolder",
[Query]
int startIndex = 0,
[Query]
int limit = 0);
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
Task<JellyfinLibraryItemsResponse> GetShowLibraryItemsWithoutPeople(
[Header("Authorization")]
@@ -93,6 +93,23 @@ public class JellyfinApiClient : IJellyfinApiClient
limit: pageSize),
(maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToMovie(lib, item)).Flatten());
public IAsyncEnumerable<Tuple<MusicVideo, int>> GetMusicVideoLibraryItems(
string address,
string authorizationHeader,
JellyfinLibrary library) =>
GetPagedLibraryItems(
"JF Music Videos",
address,
library,
library.MediaSourceId,
library.ItemId,
(service, itemId, skip, pageSize) => service.GetMusicVideoLibraryItems(
authorizationHeader,
itemId,
startIndex: skip,
limit: pageSize),
(maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToMusicVideo(lib, item)).Flatten());
public IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItemsWithoutPeople(
string address,
string authorizationHeader,
@@ -438,6 +455,15 @@ public class JellyfinApiClient : IJellyfinApiClient
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
PathInfos = GetPathInfos(response)
},
"musicvideos" => new JellyfinLibrary
{
ItemId = response.ItemId,
Name = response.Name,
MediaKind = LibraryMediaKind.MusicVideos,
ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
PathInfos = GetPathInfos(response)
},
// TODO: ??? for music libraries
"boxsets" => CacheCollectionLibraryId(response.ItemId),
_ => None
@@ -484,7 +510,7 @@ public class JellyfinApiClient : IJellyfinApiClient
}
string path = item.Path ?? string.Empty;
foreach (JellyfinPathInfo pathInfo in library.PathInfos.Filter(pi =>
foreach (JellyfinPathInfo pathInfo in Optional(library.PathInfos).Flatten().Filter(pi =>
!string.IsNullOrWhiteSpace(pi.NetworkPath)))
{
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
@@ -620,6 +646,139 @@ public class JellyfinApiClient : IJellyfinApiClient
return metadata;
}
private Option<MusicVideo> ProjectToMusicVideo(JellyfinLibrary library, JellyfinLibraryItemResponse item)
{
try
{
if (item.LocationType != "FileSystem")
{
return None;
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogInformation("STRM files are not supported; skipping {Path}", item.Path);
return None;
}
string path = item.Path ?? string.Empty;
foreach (JellyfinPathInfo pathInfo in Optional(library.PathInfos).Flatten().Filter(pi =>
!string.IsNullOrWhiteSpace(pi.NetworkPath)))
{
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
{
path = _jellyfinPathReplacementService.ReplaceNetworkPath(
(JellyfinMediaSource)library.MediaSource,
path,
pathInfo.NetworkPath,
pathInfo.Path);
}
}
var duration = TimeSpan.FromTicks(item.RunTimeTicks);
var version = new MediaVersion
{
Name = "Main",
Duration = duration,
DateAdded = item.DateCreated.UtcDateTime,
MediaFiles =
[
new MediaFile
{
Path = path,
PathHash = PathUtils.GetPathHash(path)
}
],
Streams = [],
Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration)
};
MusicVideoMetadata metadata = ProjectToMusicVideoMetadata(item);
var musicVideo = new MusicVideo
{
MediaVersions = [version],
MusicVideoMetadata = [metadata],
TraktListItems = []
};
return musicVideo;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Jellyfin music video");
return None;
}
}
private static MusicVideoMetadata ProjectToMusicVideoMetadata(JellyfinLibraryItemResponse item)
{
DateTime dateAdded = item.DateCreated.UtcDateTime;
var metadata = new MusicVideoMetadata
{
MetadataKind = MetadataKind.External,
Title = item.Name,
SortTitle = SortTitle.GetSortTitle(item.Name),
Plot = item.Overview,
Year = item.ProductionYear,
DateAdded = dateAdded,
Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(),
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
Artists = MusicVideoArtists(item)
.ToList(),
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds),
Subtitles = new List<Subtitle>()
};
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
{
metadata.ReleaseDate = releaseDate;
}
if (!string.IsNullOrWhiteSpace(item.ImageTags?.Primary))
{
var poster = new Artwork
{
ArtworkKind = ArtworkKind.Poster,
Path = $"jellyfin://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}",
DateAdded = dateAdded
};
metadata.Artwork.Add(poster);
}
if (item.BackdropImageTags?.Count > 0)
{
var fanArt = new Artwork
{
ArtworkKind = ArtworkKind.FanArt,
Path = $"jellyfin://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}",
DateAdded = dateAdded
};
metadata.Artwork.Add(fanArt);
}
return metadata;
}
private static IEnumerable<MusicVideoArtist> MusicVideoArtists(JellyfinLibraryItemResponse item)
{
List<string> artists = Optional(item.Artists).Flatten().ToList();
if (artists.Count == 0)
{
artists = Optional(item.People).Flatten()
.Filter(p => p.Type is "Artist")
.Map(p => p.Name)
.ToList();
}
return artists.Filter(name => !string.IsNullOrWhiteSpace(name))
.Distinct()
.Map(name => new MusicVideoArtist { Name = name });
}
private static Option<Actor> ProjectToActor(JellyfinPersonResponse person, DateTime dateAdded)
{
if (person.Type?.ToLowerInvariant() != "actor")
@@ -10,6 +10,7 @@ public class JellyfinLibraryItemResponse
public DateTimeOffset DateCreated { get; set; }
public long RunTimeTicks { get; set; }
public List<string> Genres { get; set; }
public List<string> Artists { get; set; }
public List<string> Tags { get; set; }
public int ProductionYear { get; set; }
public JellyfinProviderIdsResponse ProviderIds { get; set; }
@@ -0,0 +1,99 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Scanner.Application.Jellyfin;
using ErsatzTV.Scanner.Core.Interfaces;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Scanner.Tests.Application.Jellyfin;
public class SynchronizeJellyfinLibraryByIdHandlerTests
{
[TestFixture]
public class Handle
{
[Test]
public async Task Should_Scan_MusicVideo_Libraries()
{
var scannerProxy = Substitute.For<IScannerProxy>();
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
var libraryRepository = Substitute.For<ILibraryRepository>();
var configElementRepository = Substitute.For<IConfigElementRepository>();
var library = new JellyfinLibrary
{
Id = 42,
Name = "Concerts",
MediaKind = LibraryMediaKind.MusicVideos,
MediaSourceId = 7
};
var mediaSource = new JellyfinMediaSource
{
Id = 7,
Connections =
[
new JellyfinConnection
{
Address = "http://jellyfin.example",
JellyfinMediaSourceId = 7
}
]
};
mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
{
Address = "http://jellyfin.example",
ApiKey = "abc"
});
configElementRepository.GetValue<int>(
Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Option<int>>(Some(0)));
jellyfinMusicVideoLibraryScanner.ScanLibrary(
Arg.Any<JellyfinConnectionParameters>(),
library,
true,
Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
var handler = new SynchronizeJellyfinLibraryByIdHandler(
scannerProxy,
mediaSourceRepository,
jellyfinSecretStore,
jellyfinMovieLibraryScanner,
jellyfinTelevisionLibraryScanner,
jellyfinMusicVideoLibraryScanner,
libraryRepository,
configElementRepository,
Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>());
Either<BaseError, string> result = await handler.Handle(
new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
CancellationToken.None);
result.LeftToSeq().ShouldBeEmpty();
result.IsRight.ShouldBeTrue();
result.RightToSeq().Single().ShouldBe("Concerts");
await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary(
Arg.Is<JellyfinConnectionParameters>(cp =>
cp.Address == "http://jellyfin.example" &&
cp.ApiKey == "abc" &&
cp.MediaSourceId == 7),
library,
true,
Arg.Any<CancellationToken>());
await libraryRepository.Received(1).UpdateLastScan(library);
}
}
}
@@ -14,6 +14,7 @@ public class
private readonly IConfigElementRepository _configElementRepository;
private readonly IJellyfinMovieLibraryScanner _jellyfinMovieLibraryScanner;
private readonly IJellyfinMusicVideoLibraryScanner _jellyfinMusicVideoLibraryScanner;
private readonly IJellyfinSecretStore _jellyfinSecretStore;
private readonly IJellyfinTelevisionLibraryScanner _jellyfinTelevisionLibraryScanner;
@@ -28,6 +29,7 @@ public class
IJellyfinSecretStore jellyfinSecretStore,
IJellyfinMovieLibraryScanner jellyfinMovieLibraryScanner,
IJellyfinTelevisionLibraryScanner jellyfinTelevisionLibraryScanner,
IJellyfinMusicVideoLibraryScanner jellyfinMusicVideoLibraryScanner,
ILibraryRepository libraryRepository,
IConfigElementRepository configElementRepository,
ILogger<SynchronizeJellyfinLibraryByIdHandler> logger)
@@ -37,6 +39,7 @@ public class
_jellyfinSecretStore = jellyfinSecretStore;
_jellyfinMovieLibraryScanner = jellyfinMovieLibraryScanner;
_jellyfinTelevisionLibraryScanner = jellyfinTelevisionLibraryScanner;
_jellyfinMusicVideoLibraryScanner = jellyfinMusicVideoLibraryScanner;
_libraryRepository = libraryRepository;
_configElementRepository = configElementRepository;
_logger = logger;
@@ -75,6 +78,12 @@ public class
parameters.Library,
parameters.DeepScan,
cancellationToken),
LibraryMediaKind.MusicVideos =>
await _jellyfinMusicVideoLibraryScanner.ScanLibrary(
parameters.ConnectionParameters,
parameters.Library,
parameters.DeepScan,
cancellationToken),
_ => Unit.Default
};
@@ -0,0 +1,216 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Jellyfin;
public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanner
{
private const string UnknownArtist = "Unknown Artist";
private readonly IArtistRepository _artistRepository;
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinPathReplacementService _pathReplacementService;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<JellyfinMusicVideoLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMetadataRepository _metadataRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly IScannerProxy _scannerProxy;
public JellyfinMusicVideoLibraryScanner(
IScannerProxy scannerProxy,
IJellyfinApiClient jellyfinApiClient,
IJellyfinPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository,
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
ILibraryRepository libraryRepository,
IMetadataRepository metadataRepository,
ILogger<JellyfinMusicVideoLibraryScanner> logger)
{
_scannerProxy = scannerProxy;
_jellyfinApiClient = jellyfinApiClient;
_pathReplacementService = pathReplacementService;
_mediaSourceRepository = mediaSourceRepository;
_artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository;
_libraryRepository = libraryRepository;
_metadataRepository = metadataRepository;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
bool deepScan,
CancellationToken cancellationToken)
{
try
{
Option<LibraryPath> maybeLibraryPath = Optional(library.Paths).Flatten().HeadOrNone();
return await maybeLibraryPath.Match(
libraryPath => ScanLibrary(connectionParameters, library, libraryPath, cancellationToken),
() => Task.FromResult<Either<BaseError, Unit>>(
BaseError.New($"Jellyfin library {library.Id} has no library path")));
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
}
private async Task<Either<BaseError, Unit>> ScanLibrary(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
LibraryPath libraryPath,
CancellationToken cancellationToken)
{
List<JellyfinPathReplacement> pathReplacements =
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
var processed = 0;
await foreach ((MusicVideo incoming, int totalCount) in _jellyfinApiClient
.GetMusicVideoLibraryItems(
connectionParameters.Address,
connectionParameters.AuthorizationHeader,
library)
.WithCancellation(cancellationToken))
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
processed++;
decimal percentCompletion = totalCount == 0 ? 1 : Math.Clamp((decimal)processed / totalCount, 0, 1);
if (!await _scannerProxy.UpdateProgress(percentCompletion, cancellationToken))
{
return new ScanCanceled();
}
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo =
await ProcessMusicVideo(library, libraryPath, pathReplacements, incoming, cancellationToken);
foreach (BaseError error in maybeMusicVideo.LeftToSeq())
{
_logger.LogWarning("Error processing Jellyfin music video: {Error}", error.Value);
}
foreach (MediaItemScanResult<MusicVideo> result in maybeMusicVideo.RightToSeq()
.Filter(result => result.IsAdded || result.IsUpdated))
{
if (!await _scannerProxy.ReindexMediaItems([result.Item.Id], cancellationToken))
{
_logger.LogWarning("Failed to reindex media items from scanner process");
}
}
}
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> ProcessMusicVideo(
JellyfinLibrary library,
LibraryPath libraryPath,
List<JellyfinPathReplacement> pathReplacements,
MusicVideo incoming,
CancellationToken cancellationToken)
{
string localPath = GetLocalPath(pathReplacements, incoming);
string folder = Path.GetDirectoryName(localPath) ?? libraryPath.Path;
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, folder, cancellationToken);
LibraryFolder libraryFolder = await _libraryRepository.GetOrAddFolder(libraryPath, maybeParentFolder, folder);
return await GetOrAddArtist(libraryPath, incoming)
.BindT(artist => _musicVideoRepository.GetOrAdd(artist, libraryPath, libraryFolder, localPath))
.BindT(result => UpdateMusicVideo(result, incoming, localPath, cancellationToken));
}
private string GetLocalPath(List<JellyfinPathReplacement> pathReplacements, MusicVideo musicVideo) =>
_pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
musicVideo.GetHeadVersion().MediaFiles.Head().Path,
false);
private async Task<Either<BaseError, Artist>> GetOrAddArtist(LibraryPath libraryPath, MusicVideo musicVideo)
{
string artistName = musicVideo.MusicVideoMetadata
.HeadOrNone()
.Bind(metadata => Optional(metadata.Artists).Flatten().HeadOrNone())
.Map(artist => artist.Name)
.IfNone(UnknownArtist);
var metadata = new ArtistMetadata
{
MetadataKind = MetadataKind.External,
Title = artistName,
SortTitle = SortTitle.GetSortTitle(artistName),
DateAdded = DateTime.UtcNow,
Genres = [],
Styles = [],
Moods = [],
Artwork = [],
Guids = [],
Subtitles = []
};
Option<Artist> maybeArtist = await _artistRepository.GetArtistByMetadata(libraryPath.Id, metadata);
foreach (Artist artist in maybeArtist)
{
return artist;
}
Either<BaseError, MediaItemScanResult<Artist>> result =
await _artistRepository.AddArtist(libraryPath.Id, artistName, metadata);
return result.Map(scanResult => scanResult.Item);
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMusicVideo(
MediaItemScanResult<MusicVideo> result,
MusicVideo incoming,
string localPath,
CancellationToken cancellationToken)
{
result.LocalPath = localPath;
MusicVideoMetadata incomingMetadata = incoming.MusicVideoMetadata.Head();
bool updated = await UpdateMetadata(result.Item, incomingMetadata);
updated = await _metadataRepository.UpdateStatistics(result.Item, incoming.GetHeadVersion()) || updated;
if (updated)
{
result.IsUpdated = true;
}
return result;
}
private async Task<bool> UpdateMetadata(MusicVideo musicVideo, MusicVideoMetadata incoming)
{
Option<MusicVideoMetadata> maybeExisting = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone();
foreach (MusicVideoMetadata existing in maybeExisting)
{
existing.Title = incoming.Title;
existing.SortTitle = incoming.SortTitle;
existing.Plot = incoming.Plot;
existing.Year = incoming.Year;
existing.ReleaseDate = incoming.ReleaseDate;
existing.DateUpdated = DateTime.UtcNow;
existing.MetadataKind = MetadataKind.External;
return await _metadataRepository.Update(existing);
}
incoming.MusicVideoId = musicVideo.Id;
musicVideo.MusicVideoMetadata = [incoming];
return await _metadataRepository.Add(incoming);
}
}
+1
View File
@@ -236,6 +236,7 @@ public class Program
services.AddScoped<IJellyfinMovieLibraryScanner, JellyfinMovieLibraryScanner>();
services.AddScoped<IJellyfinTelevisionLibraryScanner, JellyfinTelevisionLibraryScanner>();
services.AddScoped<IJellyfinMusicVideoLibraryScanner, JellyfinMusicVideoLibraryScanner>();
services.AddScoped<IJellyfinCollectionScanner, JellyfinCollectionScanner>();
services.AddScoped<IJellyfinApiClient, JellyfinApiClient>();
services.AddScoped<IJellyfinCollectionRepository, JellyfinCollectionRepository>();
@@ -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,189 @@
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.FFmpegProfiles;
[TestFixture]
public class FFmpegProfileHandlerTests
{
private InMemoryTvContext _db = null!;
private IConfigElementRepository _configElementRepository = null!;
private ISearchTargets _searchTargets = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_configElementRepository = Substitute.For<IConfigElementRepository>();
_configElementRepository.GetValue<int>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.None);
_searchTargets = Substitute.For<ISearchTargets>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Create_Should_Return_NotFoundError_When_Resolution_Missing()
{
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result =
await handler.Handle(MakeCreate(resolutionId: 999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Update_Should_Return_NotFoundError_When_Profile_Missing()
{
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, UpdateFFmpegProfileResult> result =
await handler.Handle(MakeUpdate(999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Update_Should_Return_NotFoundError_When_Resolution_Missing()
{
await SeedProfile(1);
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, UpdateFFmpegProfileResult> result =
await handler.Handle(MakeUpdate(1, resolutionId: 999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Delete_Should_Return_NotFoundError_When_Profile_Missing()
{
var handler = new DeleteFFmpegProfileHandler(_db.Factory, _configElementRepository, _searchTargets);
Either<BaseError, Unit> result = await handler.Handle(new DeleteFFmpegProfile(999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private async Task SeedProfile(int id)
{
await using TvContext context = _db.CreateContext();
context.FFmpegProfiles.Add(new FFmpegProfile
{
Id = id,
Name = "Default",
ThreadCount = 1,
NormalizeAudio = true,
NormalizeVideo = true,
HardwareAcceleration = HardwareAccelerationKind.None,
VaapiDisplay = "drm",
VaapiDriver = VaapiDriver.Default,
VaapiDevice = "/dev/dri/renderD128",
ResolutionId = 1,
ScalingBehavior = ScalingBehavior.ScaleAndPad,
PadMode = FilterMode.Software,
VideoFormat = FFmpegProfileVideoFormat.H264,
VideoProfile = string.Empty,
VideoPreset = string.Empty,
BitDepth = FFmpegProfileBitDepth.EightBit,
VideoBitrate = 2_000,
VideoBufferSize = 4_000,
TonemapAlgorithm = FFmpegProfileTonemapAlgorithm.Linear,
AudioFormat = FFmpegProfileAudioFormat.Aac,
AudioBitrate = 192,
AudioBufferSize = 384,
NormalizeLoudnessMode = NormalizeLoudnessMode.Off,
AudioChannels = 2,
AudioSampleRate = 48_000,
NormalizeFramerate = false,
NormalizeColors = false,
DeinterlaceVideo = false
});
await context.SaveChangesAsync();
}
private static CreateFFmpegProfile MakeCreate(int resolutionId) =>
new(
"Default",
1,
true,
true,
HardwareAccelerationKind.None,
"drm",
VaapiDriver.Default,
"/dev/dri/renderD128",
null,
resolutionId,
ScalingBehavior.ScaleAndPad,
FilterMode.Software,
FFmpegProfileVideoFormat.H264,
string.Empty,
string.Empty,
false,
FFmpegProfileBitDepth.EightBit,
2_000,
4_000,
FFmpegProfileTonemapAlgorithm.Linear,
FFmpegProfileAudioFormat.Aac,
192,
384,
NormalizeLoudnessMode.Off,
null,
2,
48_000,
false,
false,
false);
private static UpdateFFmpegProfile MakeUpdate(int id, int resolutionId = 1) =>
new(
id,
"Default",
1,
true,
true,
HardwareAccelerationKind.None,
"drm",
VaapiDriver.Default,
"/dev/dri/renderD128",
null,
resolutionId,
ScalingBehavior.ScaleAndPad,
FilterMode.Software,
FFmpegProfileVideoFormat.H264,
string.Empty,
string.Empty,
false,
FFmpegProfileBitDepth.EightBit,
2_000,
4_000,
FFmpegProfileTonemapAlgorithm.Linear,
FFmpegProfileAudioFormat.Aac,
192,
384,
NormalizeLoudnessMode.Off,
null,
2,
48_000,
false,
false,
false);
}
@@ -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,104 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using MediatR;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
using Unit = LanguageExt.Unit;
using Channel = System.Threading.Channels.Channel;
namespace ErsatzTV.Tests.Application.Playouts;
[TestFixture]
public class PlayoutHandlerTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task CreateClassic_Should_Return_NotFoundError_When_Channel_Missing()
{
var handler = new CreateClassicPlayoutHandler(_worker, _db.Factory);
Either<BaseError, CreatePlayoutResponse> result =
await handler.Handle(new CreateClassicPlayout(999, 1), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task CreateClassic_Should_Return_NotFoundError_When_Schedule_Missing()
{
int channelId = await SeedChannel();
var handler = new CreateClassicPlayoutHandler(_worker, _db.Factory);
Either<BaseError, CreatePlayoutResponse> result =
await handler.Handle(new CreateClassicPlayout(channelId, 999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Delete_Should_Return_NotFoundError_When_Playout_Missing()
{
var handler = new DeletePlayoutHandler(
_worker,
_db.Factory,
new MockFileSystem(),
Substitute.For<IMediator>());
Either<BaseError, Unit> result = await handler.Handle(new DeletePlayout(999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private async Task<int> SeedChannel()
{
await using TvContext context = _db.CreateContext();
var channel = new ErsatzTV.Core.Domain.Channel(Guid.NewGuid())
{
Number = "101",
SortNumber = 101,
Name = "Handler",
Group = string.Empty,
Categories = string.Empty,
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
Playouts = [],
Artwork = [],
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous,
IsEnabled = true,
ShowInEpg = true
};
context.Channels.Add(channel);
await context.SaveChangesAsync();
return channel.Id;
}
}
@@ -0,0 +1,247 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using MediatR;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.ProgramSchedules;
[TestFixture]
public class GetProgramScheduleItemsWithDurationsHandlerTests
{
private IMediaCollectionRepository _mediaCollectionRepository = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_mediaCollectionRepository = Substitute.For<IMediaCollectionRepository>();
}
[Test]
public async Task Handle_Should_Estimate_Items_And_Total_From_Collection_Durations()
{
ProgramScheduleItemViewModel one = MakeOneItem(1, 10);
ProgramScheduleItemViewModel multipleCount = MakeMultipleItem(2, 10, MultipleMode.Count, "3");
ProgramScheduleItemViewModel multipleCollectionSize = MakeMultipleItem(3, 20, MultipleMode.CollectionSize, "0");
ProgramScheduleItemViewModel duration = MakeDurationItem(4, TimeSpan.FromMinutes(45));
ProgramScheduleItemViewModel flood = MakeFloodItem(5, 10);
ProgramScheduleItemViewModel remote = MakeOneItem(6, 30);
_mediator.Send(Arg.Is<GetProgramScheduleItems>(q => q.Id == 99), Arg.Any<CancellationToken>())
.Returns([one, multipleCount, multipleCollectionSize, duration, flood, remote]);
_mediaCollectionRepository.GetItems(10)
.Returns([MakeMovie(30), MakeMovie(60), MakeMovie(0)]);
_mediaCollectionRepository.GetItems(20)
.Returns([MakeMovie(10), MakeMovie(20)]);
_mediaCollectionRepository.GetItems(30)
.Returns([MakeRemoteStreamWithFallbackDuration(20)]);
var handler = new GetProgramScheduleItemsWithDurationsHandler(_mediator, _mediaCollectionRepository);
ProgramScheduleItemsWithDurationViewModel result =
await handler.Handle(new GetProgramScheduleItemsWithDurations(99), CancellationToken.None);
result.Items[0].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(45));
result.Items[1].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(135));
result.Items[2].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(30));
result.Items[3].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(45));
result.Items[4].DurationEstimate.ShouldBeNull();
result.Items[5].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(20));
result.TotalDurationEstimate.ShouldBe(TimeSpan.FromMinutes(275));
}
[Test]
public async Task Handle_Should_Return_Null_Total_When_No_Items_Can_Be_Estimated()
{
ProgramScheduleItemViewModel one = MakeOneItem(1, 10);
ProgramScheduleItemViewModel flood = MakeFloodItem(2, 10);
_mediator.Send(Arg.Is<GetProgramScheduleItems>(q => q.Id == 99), Arg.Any<CancellationToken>())
.Returns([one, flood]);
_mediaCollectionRepository.GetItems(10)
.Returns([MakeMovie(0)]);
var handler = new GetProgramScheduleItemsWithDurationsHandler(_mediator, _mediaCollectionRepository);
ProgramScheduleItemsWithDurationViewModel result =
await handler.Handle(new GetProgramScheduleItemsWithDurations(99), CancellationToken.None);
result.Items.ShouldAllBe(item => item.DurationEstimate == null);
result.TotalDurationEstimate.ShouldBeNull();
}
private static ProgramScheduleItemOneViewModel MakeOneItem(int id, int collectionId) =>
new(
id,
id,
StartType.Dynamic,
null,
null,
CollectionType.Collection,
MakeCollection(collectionId),
null,
null,
null,
null,
null,
null,
null,
PlaybackOrder.Shuffle,
MarathonGroupBy.None,
false,
false,
null,
FillWithGroupMode.None,
null,
GuideMode.Normal,
null,
null,
null,
null,
null,
[],
[],
null,
null,
null,
null);
private static ProgramScheduleItemMultipleViewModel MakeMultipleItem(
int id,
int collectionId,
MultipleMode multipleMode,
string count) =>
new(
id,
id,
StartType.Dynamic,
null,
null,
CollectionType.Collection,
MakeCollection(collectionId),
null,
null,
null,
null,
null,
null,
null,
PlaybackOrder.Shuffle,
MarathonGroupBy.None,
false,
false,
null,
FillWithGroupMode.None,
multipleMode,
count,
null,
GuideMode.Normal,
null,
null,
null,
null,
null,
[],
[],
null,
null,
null,
null);
private static ProgramScheduleItemDurationViewModel MakeDurationItem(int id, TimeSpan playoutDuration) =>
new(
id,
id,
StartType.Dynamic,
null,
null,
CollectionType.Collection,
MakeCollection(10),
null,
null,
null,
null,
null,
null,
null,
PlaybackOrder.Shuffle,
MarathonGroupBy.None,
false,
false,
null,
FillWithGroupMode.None,
playoutDuration,
TailMode.None,
0,
null,
GuideMode.Normal,
null,
null,
null,
null,
null,
[],
[],
null,
null,
null,
null);
private static ProgramScheduleItemFloodViewModel MakeFloodItem(int id, int collectionId) =>
new(
id,
id,
StartType.Dynamic,
null,
null,
CollectionType.Collection,
MakeCollection(collectionId),
null,
null,
null,
null,
null,
null,
null,
PlaybackOrder.Shuffle,
MarathonGroupBy.None,
false,
false,
null,
FillWithGroupMode.None,
null,
GuideMode.Normal,
null,
null,
null,
null,
null,
[],
[],
null,
null,
null,
null);
private static MediaCollectionViewModel MakeCollection(int id) =>
new(CollectionType.Collection, id, $"Collection {id}", false, MediaItemState.Normal);
private static Movie MakeMovie(int minutes) =>
new()
{
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(minutes) }]
};
private static RemoteStream MakeRemoteStreamWithFallbackDuration(int minutes) =>
new()
{
Duration = TimeSpan.FromMinutes(minutes),
MediaVersions = [new MediaVersion { Duration = TimeSpan.Zero }]
};
}
@@ -0,0 +1,217 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.ProgramSchedules;
[TestFixture]
public class ProgramScheduleHandlerTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Update_Should_Return_NotFoundError_When_Schedule_Missing()
{
var handler = new UpdateProgramScheduleHandler(_db.Factory, _worker);
Either<BaseError, UpdateProgramScheduleResult> result =
await handler.Handle(MakeUpdate(999, "Missing"), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Delete_Should_Return_NotFoundError_When_Schedule_Missing()
{
var handler = new DeleteProgramScheduleHandler(_db.Factory);
Either<BaseError, Unit> result =
await handler.Handle(new DeleteProgramSchedule(999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task AddItem_Should_Return_NotFoundError_When_Schedule_Missing()
{
var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker);
Either<BaseError, ProgramScheduleItemViewModel> result =
await handler.Handle(MakeAdd(999, CollectionType.SearchQuery), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task AddItem_Should_Return_ValidationError_When_Collection_Type_Is_Invalid()
{
int scheduleId = await SeedSchedule();
var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker);
Either<BaseError, ProgramScheduleItemViewModel> result =
await handler.Handle(MakeAdd(scheduleId, CollectionType.Collection), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("[Collection] is required");
}
[Test]
public async Task ReplaceItems_Should_Return_NotFoundError_When_Schedule_Missing()
{
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
await handler.Handle(
new ReplaceProgramScheduleItems(999, [MakeReplace(0)]),
CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task DeleteItem_Should_Return_NotFoundError_When_Item_Missing()
{
int scheduleId = await SeedSchedule();
var handler = new DeleteProgramScheduleItemHandler(_db.Factory, _worker);
Either<BaseError, Unit> result =
await handler.Handle(new DeleteProgramScheduleItem(scheduleId, 999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
private async Task<int> SeedSchedule()
{
await using TvContext context = _db.CreateContext();
var schedule = new ProgramSchedule
{
Name = "Handlers",
Items = [],
Playouts = [],
ProgramScheduleAlternates = []
};
context.ProgramSchedules.Add(schedule);
await context.SaveChangesAsync();
return schedule.Id;
}
private static UpdateProgramSchedule MakeUpdate(int scheduleId, string name) =>
new(
scheduleId,
name,
KeepMultiPartEpisodesTogether: true,
TreatCollectionsAsShows: true,
ShuffleScheduleItems: false,
RandomStartPoint: false,
FixedStartTimeBehavior.Flexible);
private static AddProgramScheduleItem MakeAdd(int scheduleId, CollectionType collectionType) =>
new(
scheduleId,
StartType.Dynamic,
StartTime: null,
FixedStartTimeBehavior: null,
PlayoutMode.One,
collectionType,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: null,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null,
SearchTitle: "News",
SearchQuery: "news",
PlaybackOrder.Shuffle,
MarathonGroupBy.None,
MarathonShuffleGroups: false,
MarathonShuffleItems: false,
MarathonBatchSize: null,
FillWithGroupMode.None,
MultipleMode.Count,
MultipleCount: "1",
PlayoutDuration: null,
TailMode.None,
DiscardToFillAttempts: null,
CustomTitle: null,
GuideMode.Normal,
PreRollFillerId: null,
MidRollFillerId: null,
PostRollFillerId: null,
TailFillerId: null,
FallbackFillerId: null,
WatermarkIds: [],
GraphicsElementIds: [],
PreferredAudioLanguageCode: null,
PreferredAudioTitle: null,
PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
private static ReplaceProgramScheduleItem MakeReplace(int index)
{
AddProgramScheduleItem add = MakeAdd(1, CollectionType.SearchQuery);
return new ReplaceProgramScheduleItem(
index,
add.StartType,
add.StartTime,
add.FixedStartTimeBehavior,
add.PlayoutMode,
add.CollectionType,
add.CollectionId,
add.MultiCollectionId,
add.SmartCollectionId,
add.RerunCollectionId,
add.MediaItemId,
add.PlaylistId,
add.SearchTitle,
add.SearchQuery,
add.PlaybackOrder,
add.MarathonGroupBy,
add.MarathonShuffleGroups,
add.MarathonShuffleItems,
add.MarathonBatchSize,
add.FillWithGroupMode,
add.MultipleMode,
add.MultipleCount,
add.PlayoutDuration,
add.TailMode,
add.DiscardToFillAttempts,
add.CustomTitle,
add.GuideMode,
add.PreRollFillerId,
add.MidRollFillerId,
add.PostRollFillerId,
add.TailFillerId,
add.FallbackFillerId,
add.WatermarkIds,
add.GraphicsElementIds,
add.PreferredAudioLanguageCode,
add.PreferredAudioTitle,
add.PreferredSubtitleLanguageCode,
add.SubtitleMode);
}
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,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,122 @@
using System.Reflection;
using ErsatzTV;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Filters;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ApiControllerSecurityTests
{
private static readonly bool ApiKeyAuthorizationFilterIsGlobal = IsApiKeyAuthorizationFilterRegisteredGlobally();
[Test]
public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt()
{
Type[] apiControllers =
[
typeof(ChannelController),
typeof(CollectionController),
typeof(FFmpegProfileController),
typeof(LibrariesController),
typeof(MaintenanceController),
typeof(PlayoutController),
typeof(ScannerController),
typeof(ScheduleController),
typeof(ScriptedScheduleController),
typeof(SessionController),
typeof(SmartCollectionController)
];
foreach (Type controllerType in apiControllers)
{
bool controllerSkipsApiKey = controllerType
.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true)
.Any();
foreach (MethodInfo action in controllerType
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
bool isMutating = action
.GetCustomAttributes<HttpMethodAttribute>(inherit: true)
.SelectMany(a => a.HttpMethods)
.Any(m => m is "POST" or "PUT" or "PATCH" or "DELETE");
if (!isMutating)
{
continue;
}
bool actionSkipsApiKey = action
.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true)
.Any();
(controllerSkipsApiKey || actionSkipsApiKey || IsGloballyProtected())
.ShouldBeTrue($"{controllerType.Name}.{action.Name} must be covered by global API write auth or explicitly exempt");
}
}
}
[Test]
public void ScannerController_Should_Be_Only_Api_Key_Exempt_Api_Controller()
{
Type[] exemptControllers = typeof(ScannerController)
.Assembly
.GetTypes()
.Where(t => t.Namespace == typeof(ScannerController).Namespace)
.Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
.Where(t => t.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true).Any())
.ToArray();
exemptControllers.ShouldBe([typeof(ScannerController)]);
}
[Test]
public void Startup_Should_Register_ApiKeyAuthorizationFilter_Globally()
{
ApiKeyAuthorizationFilterIsGlobal.ShouldBeTrue();
}
private static bool IsGloballyProtected() => ApiKeyAuthorizationFilterIsGlobal;
private static bool IsApiKeyAuthorizationFilterRegisteredGlobally()
{
var settings = new Dictionary<string, string?>
{
["provider"] = "sqlite",
["ConnectionStrings:Data"] = "Data Source=:memory:"
};
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(settings)
.Build();
var environment = Substitute.For<IWebHostEnvironment>();
environment.ApplicationName.Returns("ErsatzTV");
environment.EnvironmentName.Returns("Development");
environment.ContentRootPath.Returns(TestContext.CurrentContext.TestDirectory);
environment.WebRootPath.Returns(TestContext.CurrentContext.TestDirectory);
environment.ContentRootFileProvider.Returns(new NullFileProvider());
environment.WebRootFileProvider.Returns(new NullFileProvider());
var services = new ServiceCollection();
new Startup(configuration, environment).ConfigureServices(services);
using ServiceProvider provider = services.BuildServiceProvider();
MvcOptions options = provider.GetRequiredService<IOptions<MvcOptions>>().Value;
return options.Filters
.OfType<ServiceFilterAttribute>()
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
}
}
@@ -36,6 +36,35 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.Update), StatusCodes.Status404NotFound)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.GetItems), StatusCodes.Status404NotFound)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.AddItem), StatusCodes.Status404NotFound)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.AddItem), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.ReplaceItems), StatusCodes.Status404NotFound)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.ReplaceItems), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status404NotFound)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.UpdateOne), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.UpdateOne), StatusCodes.Status401Unauthorized)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.UpdateOne), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status401Unauthorized)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status422UnprocessableEntity)]
public void Api_Error_Response_Metadata_Should_Document_ProblemDetails(
Type controllerType,
string actionName,
@@ -50,4 +79,5 @@ public class ApiErrorResponseMetadataTests
metadata.ShouldNotBeNull($"{controllerType.Name}.{actionName} should document HTTP {statusCode}");
metadata.Type.ShouldBe(typeof(ProblemDetails));
}
}
@@ -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
};
}
@@ -1,62 +0,0 @@
using System.Linq;
using System.Reflection;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Filters;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
/// <summary>
/// Guards the API-key write-path contract for <see cref="ChannelController" />: every mutating
/// action (POST/PUT/PATCH/DELETE) must be covered by <see cref="ApiKeyAuthorizationFilter" />.
/// The filter is applied at the controller level, so this also protects any future write endpoint
/// added to the controller (regression net for the ResetPlayout bypass).
/// </summary>
[TestFixture]
public class ChannelControllerSecurityTests
{
[Test]
public void Controller_Should_Apply_ApiKeyAuthorizationFilter()
{
ServiceFilterAttribute? filter = typeof(ChannelController)
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
filter.ShouldNotBeNull("ChannelController must carry the ApiKeyAuthorizationFilter at the class level");
}
[Test]
public void Every_Mutating_Action_Should_Be_Protected()
{
MethodInfo[] actions = typeof(ChannelController)
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
bool controllerHasFilter = typeof(ChannelController)
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
foreach (MethodInfo action in actions)
{
bool isMutating = action
.GetCustomAttributes<HttpMethodAttribute>(inherit: true)
.SelectMany(a => a.HttpMethods)
.Any(m =>
m is "POST" or "PUT" or "PATCH" or "DELETE");
if (!isMutating)
{
continue;
}
bool actionHasFilter = action
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
(controllerHasFilter || actionHasFilter)
.ShouldBeTrue($"Mutating action {action.Name} is not protected by ApiKeyAuthorizationFilter");
}
}
}
@@ -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,
@@ -1,56 +0,0 @@
using System.Reflection;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Filters;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class CollectionControllerSecurityTests
{
[TestCase(typeof(CollectionController))]
[TestCase(typeof(SmartCollectionController))]
public void Controller_Should_Apply_ApiKeyAuthorizationFilter(Type controllerType)
{
ServiceFilterAttribute? filter = controllerType
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
filter.ShouldNotBeNull($"{controllerType.Name} must carry ApiKeyAuthorizationFilter at the class level");
}
[TestCase(typeof(CollectionController))]
[TestCase(typeof(SmartCollectionController))]
public void Every_Mutating_Action_Should_Be_Protected(Type controllerType)
{
MethodInfo[] actions = controllerType
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
bool controllerHasFilter = controllerType
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
foreach (MethodInfo action in actions)
{
bool isMutating = action
.GetCustomAttributes<HttpMethodAttribute>(inherit: true)
.SelectMany(a => a.HttpMethods)
.Any(m => m is "POST" or "PUT" or "PATCH" or "DELETE");
if (!isMutating)
{
continue;
}
bool actionHasFilter = action
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
(controllerHasFilter || actionHasFilter)
.ShouldBeTrue($"Mutating action {controllerType.Name}.{action.Name} is not protected");
}
}
}
@@ -0,0 +1,280 @@
using System.Reflection;
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class FFmpegProfileControllerTests
{
private FFmpegProfileController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new FFmpegProfileController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute("GET", "/api/ffmpeg/profiles");
ShouldHaveActionRoute("GET", "/api/ffmpeg/profiles/{id:int}");
ShouldHaveActionRoute("POST", "/api/ffmpeg/profiles");
ShouldHaveActionRoute("PUT", "/api/ffmpeg/profiles/{id:int}");
ShouldHaveActionRoute("DELETE", "/api/ffmpeg/profiles/{id:int}");
}
[Test]
public void Mutations_Should_Use_Request_Dtos_For_Wire_Contract()
{
ParameterInfo createRequest = typeof(FFmpegProfileController)
.GetMethod(nameof(FFmpegProfileController.AddOne))!
.GetParameters()
.Single(p => p.Name == "request");
ParameterInfo updateRequest = typeof(FFmpegProfileController)
.GetMethod(nameof(FFmpegProfileController.UpdateOne))!
.GetParameters()
.Single(p => p.Name == "request");
createRequest.ParameterType.ShouldBe(typeof(CreateFFmpegProfileRequest));
updateRequest.ParameterType.ShouldBe(typeof(UpdateFFmpegProfileRequest));
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
FFmpegFullProfileResponseModel vm = MakeVm(4);
_mediator.Send(Arg.Any<GetFFmpegFullProfileByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<FFmpegFullProfileResponseModel>.Some(vm));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetFFmpegFullProfileByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<FFmpegFullProfileResponseModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(404);
problemDetails.Title.ShouldBe("Resource not found");
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
_mediator.Send(Arg.Any<CreateFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateFFmpegProfileResult>(new CreateFFmpegProfileResult(7)));
FFmpegFullProfileResponseModel vm = MakeVm(7);
_mediator.Send(Arg.Any<GetFFmpegFullProfileByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<FFmpegFullProfileResponseModel>.Some(vm));
IActionResult result = await _controller.AddOne(MakeCreateRequest(), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/ffmpeg/profiles/7");
created.Value.ShouldBe(vm);
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreateFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateFFmpegProfileResult>(BaseError.New("bad")));
IActionResult result = await _controller.AddOne(MakeCreateRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Create_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<CreateFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateFFmpegProfileResult>(new NotFoundError("missing")));
IActionResult result = await _controller.AddOne(MakeCreateRequest(), CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id_To_Command()
{
_mediator.Send(Arg.Any<UpdateFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, UpdateFFmpegProfileResult>(new UpdateFFmpegProfileResult(8)));
FFmpegFullProfileResponseModel vm = MakeVm(8);
_mediator.Send(Arg.Any<GetFFmpegFullProfileByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<FFmpegFullProfileResponseModel>.Some(vm));
IActionResult result = await _controller.UpdateOne(8, MakeUpdateRequest(), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
await _mediator.Received(1).Send(
Arg.Is<UpdateFFmpegProfile>(c => c.FFmpegProfileId == 8),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<UpdateFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, UpdateFFmpegProfileResult>(new NotFoundError("missing")));
IActionResult result = await _controller.UpdateOne(99, MakeUpdateRequest(), CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.DeleteProfileAsync(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.DeleteProfileAsync(9, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
private static void ShouldHaveActionRoute(string httpMethod, string route)
{
bool exists = typeof(FFmpegProfileController)
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.SelectMany(m => m.GetCustomAttributes<HttpMethodAttribute>(inherit: true))
.Any(a => a.HttpMethods.Contains(httpMethod) && a.Template == route);
exists.ShouldBeTrue($"Missing route {httpMethod} {route}");
}
private static FFmpegFullProfileResponseModel MakeVm(int id) =>
new(
id,
"Default",
1,
HardwareAccelerationKind.None,
"drm",
VaapiDriver.Default,
"/dev/dri/renderD128",
null,
"HD",
ScalingBehavior.ScaleAndPad,
FFmpegProfileVideoFormat.H264,
string.Empty,
string.Empty,
false,
FFmpegProfileBitDepth.EightBit,
2_000,
4_000,
FFmpegProfileTonemapAlgorithm.Linear,
FFmpegProfileAudioFormat.Aac,
192,
384,
NormalizeLoudnessMode.Off,
2,
48_000,
false,
false);
private static CreateFFmpegProfileRequest MakeCreateRequest() =>
new(
"Default",
1,
true,
true,
HardwareAccelerationKind.None,
"drm",
VaapiDriver.Default,
"/dev/dri/renderD128",
null,
1,
ScalingBehavior.ScaleAndPad,
FilterMode.Software,
FFmpegProfileVideoFormat.H264,
string.Empty,
string.Empty,
false,
FFmpegProfileBitDepth.EightBit,
2_000,
4_000,
FFmpegProfileTonemapAlgorithm.Linear,
FFmpegProfileAudioFormat.Aac,
192,
384,
NormalizeLoudnessMode.Off,
null,
2,
48_000,
false,
false,
false);
private static UpdateFFmpegProfileRequest MakeUpdateRequest() =>
new(
"Default",
1,
true,
true,
HardwareAccelerationKind.None,
"drm",
VaapiDriver.Default,
"/dev/dri/renderD128",
null,
1,
ScalingBehavior.ScaleAndPad,
FilterMode.Software,
FFmpegProfileVideoFormat.H264,
string.Empty,
string.Empty,
false,
FFmpegProfileBitDepth.EightBit,
2_000,
4_000,
FFmpegProfileTonemapAlgorithm.Linear,
FFmpegProfileAudioFormat.Aac,
192,
384,
NormalizeLoudnessMode.Off,
null,
2,
48_000,
false,
false,
false);
}
@@ -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);
}
}
@@ -7,6 +7,22 @@ namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class OpenApiErrorResponseContractTests
{
[Test]
public void Static_OpenApi_Should_Document_Schedule_Item_Discriminator_As_String_Enum()
{
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
JsonElement playoutMode = document.RootElement
.GetProperty("components")
.GetProperty("schemas")
.GetProperty("PlayoutMode");
playoutMode.GetProperty("type").GetString().ShouldBe("string");
playoutMode.GetProperty("enum").EnumerateArray()
.Select(e => e.GetString())
.ShouldContain("One");
}
[TestCase("/api/channels/{id}", "get", "404")]
[TestCase("/api/channels", "post", "404")]
[TestCase("/api/channels", "post", "422")]
@@ -33,6 +49,37 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/smart-collections/{id}", "put", "422")]
[TestCase("/api/smart-collections/{id}", "delete", "404")]
[TestCase("/api/smart-collections/{id}", "delete", "422")]
[TestCase("/api/schedules/{id}", "get", "404")]
[TestCase("/api/schedules", "post", "404")]
[TestCase("/api/schedules", "post", "422")]
[TestCase("/api/schedules/{id}", "put", "404")]
[TestCase("/api/schedules/{id}", "put", "422")]
[TestCase("/api/schedules/{id}", "delete", "404")]
[TestCase("/api/schedules/{id}", "delete", "422")]
[TestCase("/api/schedules/{id}/items", "get", "404")]
[TestCase("/api/schedules/{id}/items", "post", "404")]
[TestCase("/api/schedules/{id}/items", "post", "422")]
[TestCase("/api/schedules/{id}/items", "put", "404")]
[TestCase("/api/schedules/{id}/items", "put", "422")]
[TestCase("/api/schedules/{id}/items/{itemId}", "delete", "404")]
[TestCase("/api/schedules/{id}/items/{itemId}", "delete", "422")]
[TestCase("/api/playouts/{id}", "get", "404")]
[TestCase("/api/playouts", "post", "404")]
[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")]
[TestCase("/api/ffmpeg/profiles", "post", "422")]
[TestCase("/api/ffmpeg/profiles/{id}", "put", "404")]
[TestCase("/api/ffmpeg/profiles/{id}", "put", "401")]
[TestCase("/api/ffmpeg/profiles/{id}", "put", "422")]
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "404")]
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "401")]
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "422")]
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
string path,
string method,
@@ -0,0 +1,332 @@
using System.Reflection;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api;
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;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class PlayoutControllerTests
{
private PlayoutController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new PlayoutController(_mediator);
}
[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}");
}
[Test]
public void Create_Should_Use_Stable_Request_Dto()
{
MethodInfo action = typeof(PlayoutController).GetMethod(nameof(PlayoutController.Create))
?? throw new AssertionException($"Missing action {nameof(PlayoutController.Create)}");
action.GetParameters()[0].ParameterType.ShouldBe(typeof(CreatePlayoutRequest));
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
_mediator.Send(Arg.Any<CreateClassicPlayout>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreatePlayoutResponse>(new CreatePlayoutResponse(9)));
PlayoutNameViewModel vm = MakePlayout(9);
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(vm));
IActionResult result = await _controller.Create(new CreatePlayoutRequest(3, 4), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/playouts/9");
created.Value.ShouldBe(ToResponse(vm));
}
[Test]
public async Task Create_Should_Map_Request_To_Classic_Playout_Command()
{
_mediator.Send(Arg.Any<CreateClassicPlayout>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreatePlayoutResponse>(new CreatePlayoutResponse(9)));
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
await _controller.Create(new CreatePlayoutRequest(3, 4), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateClassicPlayout>(c => c.ChannelId == 3 && c.ProgramScheduleId == 4),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Return_404_For_NotFoundError_With_ProblemDetails()
{
_mediator.Send(Arg.Any<CreateClassicPlayout>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreatePlayoutResponse>(new NotFoundError("missing")));
IActionResult result = await _controller.Create(new CreatePlayoutRequest(404, 4), CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(404);
problem.Title.ShouldBe("Resource not found");
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error_With_ProblemDetails()
{
_mediator.Send(Arg.Any<CreateClassicPlayout>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreatePlayoutResponse>(BaseError.New("bad")));
IActionResult result = await _controller.Create(new CreatePlayoutRequest(3, 4), CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(422);
problem.Title.ShouldBe("Validation failed");
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeletePlayout>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<DeletePlayout>(c => c.PlayoutId == 9),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Delete_Should_Return_404_For_NotFoundError_With_ProblemDetails()
{
_mediator.Send(Arg.Any<DeletePlayout>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.Delete(404, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(404);
problem.Title.ShouldBe("Resource not found");
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
PlayoutNameViewModel vm = MakePlayout(9);
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(vm));
IActionResult result = await _controller.GetById(9, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(ToResponse(vm));
}
[Test]
public async Task GetById_Should_Return_404_For_None_With_ProblemDetails()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.GetById(9, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(404);
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,
PlayoutScheduleKind.Classic,
"Channel",
"101",
ChannelPlayoutMode.Continuous,
"Schedule",
string.Empty,
null,
new PlayoutBuildStatus());
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) =>
PlayoutResponseModel.From(
vm.PlayoutId,
vm.ScheduleKind,
vm.ChannelName,
vm.ChannelNumber,
vm.PlayoutMode,
vm.ScheduleName,
vm.ScheduleFile,
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)
{
MethodInfo action = typeof(PlayoutController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}
@@ -0,0 +1,387 @@
using System.Reflection;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ScheduleControllerTests
{
private ScheduleController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new ScheduleController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute(nameof(ScheduleController.GetAll), "GET", "/api/schedules");
ShouldHaveActionRoute(nameof(ScheduleController.GetById), "GET", "/api/schedules/{id:int}");
ShouldHaveActionRoute(nameof(ScheduleController.Create), "POST", "/api/schedules");
ShouldHaveActionRoute(nameof(ScheduleController.Update), "PUT", "/api/schedules/{id:int}");
ShouldHaveActionRoute(nameof(ScheduleController.Delete), "DELETE", "/api/schedules/{id:int}");
ShouldHaveActionRoute(nameof(ScheduleController.GetItems), "GET", "/api/schedules/{id:int}/items");
ShouldHaveActionRoute(nameof(ScheduleController.AddItem), "POST", "/api/schedules/{id:int}/items");
ShouldHaveActionRoute(nameof(ScheduleController.ReplaceItems), "PUT", "/api/schedules/{id:int}/items");
ShouldHaveActionRoute(
nameof(ScheduleController.DeleteItem),
"DELETE",
"/api/schedules/{id:int}/items/{itemId:int}");
}
[Test]
public void Update_Should_Use_Stable_Update_Request_Dto()
{
MethodInfo action = typeof(ScheduleController).GetMethod(nameof(ScheduleController.Update))
?? throw new AssertionException($"Missing action {nameof(ScheduleController.Update)}");
action.GetParameters()[1].ParameterType.ShouldBe(typeof(UpdateScheduleRequest));
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
_mediator.Send(Arg.Any<CreateProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateProgramScheduleResult>(new CreateProgramScheduleResult(5)));
ProgramScheduleViewModel vm = MakeSchedule(5, "Daily");
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(vm));
IActionResult result = await _controller.Create(MakeScheduleRequest("Daily"), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/schedules/5");
created.Value.ShouldBe(vm);
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error_With_ProblemDetails()
{
_mediator.Send(Arg.Any<CreateProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateProgramScheduleResult>(BaseError.New("bad")));
IActionResult result = await _controller.Create(MakeScheduleRequest(string.Empty), CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(422);
problem.Title.ShouldBe("Validation failed");
}
[Test]
public async Task Create_Should_Map_Request_To_Command()
{
_mediator.Send(Arg.Any<CreateProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateProgramScheduleResult>(new CreateProgramScheduleResult(5)));
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(5, "Daily")));
await _controller.Create(MakeScheduleRequest("Daily"), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateProgramSchedule>(c =>
c.Name == "Daily" &&
c.KeepMultiPartEpisodesTogether &&
c.TreatCollectionsAsShows &&
c.ShuffleScheduleItems &&
c.RandomStartPoint &&
c.FixedStartTimeBehavior == FixedStartTimeBehavior.Flexible),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id()
{
_mediator.Send(Arg.Any<UpdateProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, UpdateProgramScheduleResult>(new UpdateProgramScheduleResult(7)));
ProgramScheduleViewModel vm = MakeSchedule(7, "Updated");
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(vm));
IActionResult result = await _controller.Update(7, MakeUpdateScheduleRequest("Updated"), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
await _mediator.Received(1).Send(
Arg.Is<UpdateProgramSchedule>(c => c.ProgramScheduleId == 7 && c.Name == "Updated"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_For_NotFoundError_With_ProblemDetails()
{
_mediator.Send(Arg.Any<UpdateProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, UpdateProgramScheduleResult>(new NotFoundError("missing")));
IActionResult result = await _controller.Update(99, MakeUpdateScheduleRequest("Missing"), CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(404);
problem.Title.ShouldBe("Resource not found");
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(3, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.Delete(99, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
ProgramScheduleViewModel vm = MakeSchedule(4, "Daily");
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(vm));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
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);
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>());
}
[Test]
public async Task GetItems_Should_Return_404_When_Schedule_Missing()
{
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.None);
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task AddItem_Should_Return_201_With_Location_And_Map_Route_Id()
{
ProgramScheduleItemViewModel item = MakeOneItem(12);
_mediator.Send(Arg.Any<AddProgramScheduleItem>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ProgramScheduleItemViewModel>(item));
IActionResult result = await _controller.AddItem(4, MakeItemRequest(PlayoutMode.One), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/schedules/4/items/12");
created.Value.ShouldBe(item);
await _mediator.Received(1).Send(
Arg.Is<AddProgramScheduleItem>(c => c.ProgramScheduleId == 4 && c.PlayoutMode == PlayoutMode.One),
Arg.Any<CancellationToken>());
}
[Test]
public async Task AddItem_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<AddProgramScheduleItem>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, ProgramScheduleItemViewModel>(BaseError.New("bad")));
IActionResult result = await _controller.AddItem(4, MakeItemRequest(PlayoutMode.Duration), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task ReplaceItems_Should_Return_200_With_Items_And_Map_Route_Id()
{
List<ProgramScheduleItemViewModel> items = [MakeOneItem(21), MakeOneItem(22)];
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>(items));
IActionResult result = await _controller.ReplaceItems(
4,
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One), MakeItemRequest(PlayoutMode.Multiple)]),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(items);
await _mediator.Received(1).Send(
Arg.Is<ReplaceProgramScheduleItems>(c =>
c.ProgramScheduleId == 4 &&
c.Items.Count == 2 &&
c.Items[0].Index == 0 &&
c.Items[1].PlayoutMode == PlayoutMode.Multiple),
Arg.Any<CancellationToken>());
}
[Test]
public async Task DeleteItem_Should_Return_204_And_Map_Route_Ids()
{
_mediator.Send(Arg.Any<DeleteProgramScheduleItem>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.DeleteItem(4, 12, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<DeleteProgramScheduleItem>(c => c.ProgramScheduleId == 4 && c.ProgramScheduleItemId == 12),
Arg.Any<CancellationToken>());
}
private static CreateScheduleRequest MakeScheduleRequest(string name) =>
new(
name,
KeepMultiPartEpisodesTogether: true,
TreatCollectionsAsShows: true,
ShuffleScheduleItems: true,
RandomStartPoint: true,
FixedStartTimeBehavior: FixedStartTimeBehavior.Flexible);
private static UpdateScheduleRequest MakeUpdateScheduleRequest(string name) =>
new(
name,
KeepMultiPartEpisodesTogether: true,
TreatCollectionsAsShows: true,
ShuffleScheduleItems: true,
RandomStartPoint: true,
FixedStartTimeBehavior: FixedStartTimeBehavior.Flexible);
private static ScheduleItemRequest MakeItemRequest(PlayoutMode playoutMode) =>
new(
StartType.Dynamic,
StartTime: null,
FixedStartTimeBehavior: null,
playoutMode,
CollectionType.SearchQuery,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: null,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null,
SearchTitle: "News",
SearchQuery: "news",
PlaybackOrder: PlaybackOrder.Shuffle,
MarathonGroupBy: MarathonGroupBy.None,
MarathonShuffleGroups: false,
MarathonShuffleItems: false,
MarathonBatchSize: null,
FillWithGroupMode: FillWithGroupMode.None,
MultipleMode: MultipleMode.Count,
MultipleCount: "2",
PlayoutDuration: TimeSpan.FromMinutes(30),
TailMode: TailMode.None,
DiscardToFillAttempts: 3,
CustomTitle: null,
GuideMode: GuideMode.Normal,
PreRollFillerId: null,
MidRollFillerId: null,
PostRollFillerId: null,
TailFillerId: null,
FallbackFillerId: null,
WatermarkIds: [],
GraphicsElementIds: [],
PreferredAudioLanguageCode: null,
PreferredAudioTitle: null,
PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
private static ProgramScheduleViewModel MakeSchedule(int id, string name) =>
new(id, name, true, true, false, false, FixedStartTimeBehavior.Flexible);
private static ProgramScheduleItemOneViewModel MakeOneItem(int id) =>
new(
id,
0,
StartType.Dynamic,
null,
null,
CollectionType.SearchQuery,
null,
null,
null,
null,
null,
null,
"News",
"news",
PlaybackOrder.Shuffle,
MarathonGroupBy.None,
false,
false,
null,
FillWithGroupMode.None,
null,
GuideMode.Normal,
null,
null,
null,
null,
null,
[],
[],
null,
null,
null,
null);
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(ScheduleController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}
@@ -5,7 +5,6 @@ using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Errors;
using ErsatzTV.Filters;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@@ -41,16 +40,6 @@ public class SmartCollectionControllerTests
ShouldHaveActionRoute(nameof(SmartCollectionController.Delete), "DELETE", "/api/smart-collections/{id:int}");
}
[Test]
public void Controller_Should_Apply_ApiKeyAuthorizationFilter()
{
ServiceFilterAttribute? filter = typeof(SmartCollectionController)
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
filter.ShouldNotBeNull("SmartCollectionController must carry ApiKeyAuthorizationFilter at the class level");
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
@@ -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();
}
}
@@ -14,16 +14,27 @@ namespace ErsatzTV.Tests.Filters;
[TestFixture]
public class ApiKeyAuthorizationFilterTests
{
private static AuthorizationFilterContext MakeContext(string method, string? apiKeyHeader)
private static AuthorizationFilterContext MakeContext(
string method,
string? apiKeyHeader,
string path = "/api/channels",
bool skipApiKeyAuthorization = false)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = method;
httpContext.Request.Path = path;
if (apiKeyHeader is not null)
{
httpContext.Request.Headers[ApiKeyAuthorizationFilter.HeaderName] = apiKeyHeader;
}
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
var actionDescriptor = new ActionDescriptor();
if (skipApiKeyAuthorization)
{
actionDescriptor.EndpointMetadata = [new SkipApiKeyAuthorizationAttribute()];
}
var actionContext = new ActionContext(httpContext, new RouteData(), actionDescriptor);
return new AuthorizationFilterContext(actionContext, new List<IFilterMetadata>());
}
@@ -60,7 +71,10 @@ public class ApiKeyAuthorizationFilterTests
{
AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null);
MakeFilter(configuredKey: "secret").OnAuthorization(context);
context.Result.ShouldBeOfType<UnauthorizedResult>();
var result = context.Result.ShouldBeOfType<UnauthorizedObjectResult>();
var problemDetails = result.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(StatusCodes.Status401Unauthorized);
problemDetails.Title.ShouldBe("Unauthorized");
}
[Test]
@@ -68,7 +82,10 @@ public class ApiKeyAuthorizationFilterTests
{
AuthorizationFilterContext context = MakeContext("DELETE", apiKeyHeader: "wrong");
MakeFilter(configuredKey: "secret").OnAuthorization(context);
context.Result.ShouldBeOfType<UnauthorizedResult>();
var result = context.Result.ShouldBeOfType<UnauthorizedObjectResult>();
var problemDetails = result.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(StatusCodes.Status401Unauthorized);
problemDetails.Title.ShouldBe("Unauthorized");
}
[Test]
@@ -86,4 +103,25 @@ public class ApiKeyAuthorizationFilterTests
MakeFilter(configuredKey: "secret").OnAuthorization(context);
context.Result.ShouldBeNull();
}
[Test]
public void Should_Allow_Non_Api_Mutating_Request_Even_When_Key_Configured()
{
AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null, path: "/iptv/channels.m3u");
MakeFilter(configuredKey: "secret").OnAuthorization(context);
context.Result.ShouldBeNull();
}
[Test]
public void Should_Allow_Api_Mutating_Request_When_Endpoint_Skips_Api_Key_Authorization()
{
AuthorizationFilterContext context = MakeContext(
"POST",
apiKeyHeader: null,
path: "/api/scan/0f8fad5b-d9cb-469f-a165-70867728950e/progress",
skipApiKeyAuthorization: true);
MakeFilter(configuredKey: "secret").OnAuthorization(context);
context.Result.ShouldBeNull();
}
}
@@ -0,0 +1,130 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
using Unit = LanguageExt.Unit;
using Channel = System.Threading.Channels.Channel;
namespace ErsatzTV.Tests.Integration;
[TestFixture]
public class PlayoutLifecycleIntegrationTests
{
private InMemoryTvContext _db = null!;
private Channel<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task CreateClassic_Should_Link_Channel_And_Schedule_And_Enqueue_Build()
{
(int channelId, int scheduleId) = await SeedChannelAndSchedule();
var handler = new CreateClassicPlayoutHandler(_worker.Writer, _db.Factory);
Either<BaseError, CreatePlayoutResponse> result =
await handler.Handle(new CreateClassicPlayout(channelId, scheduleId), CancellationToken.None);
CreatePlayoutResponse response = result.Match(
Right: r => r,
Left: error => throw new AssertionException(error.Value));
await using TvContext context = _db.CreateContext();
Playout playout = await context.Playouts.SingleAsync();
playout.Id.ShouldBe(response.PlayoutId);
playout.ChannelId.ShouldBe(channelId);
playout.ProgramScheduleId.ShouldBe(scheduleId);
playout.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic);
IBackgroundServiceRequest backgroundRequest = await _worker.Reader.ReadAsync();
backgroundRequest.ShouldBeOfType<BuildPlayout>().PlayoutId.ShouldBe(playout.Id);
}
[Test]
public async Task Delete_Should_Remove_Playout_Without_Deleting_Channel_Or_Schedule()
{
(int channelId, int scheduleId) = await SeedChannelAndSchedule();
var createHandler = new CreateClassicPlayoutHandler(_worker.Writer, _db.Factory);
int playoutId = (await createHandler.Handle(new CreateClassicPlayout(channelId, scheduleId), CancellationToken.None))
.Match(Right: r => r.PlayoutId, Left: error => throw new AssertionException(error.Value));
var deleteHandler = new DeletePlayoutHandler(
_worker.Writer,
_db.Factory,
new MockFileSystem(),
Substitute.For<IMediator>());
Either<BaseError, Unit> result = await deleteHandler.Handle(new DeletePlayout(playoutId), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = _db.CreateContext();
(await context.Playouts.CountAsync()).ShouldBe(0);
(await context.Channels.CountAsync(c => c.Id == channelId)).ShouldBe(1);
(await context.ProgramSchedules.CountAsync(ps => ps.Id == scheduleId)).ShouldBe(1);
}
private async Task<(int ChannelId, int ScheduleId)> SeedChannelAndSchedule()
{
await using TvContext context = _db.CreateContext();
var channel = new ErsatzTV.Core.Domain.Channel(Guid.NewGuid())
{
Number = "101",
SortNumber = 101,
Name = "Lifecycle",
Group = string.Empty,
Categories = string.Empty,
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
Playouts = [],
Artwork = [],
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous,
IsEnabled = true,
ShowInEpg = true
};
var schedule = new ProgramSchedule
{
Name = "Lifecycle",
Items =
[
new ProgramScheduleItemOne
{
Index = 0,
CollectionType = CollectionType.SearchQuery,
SearchQuery = "news",
PlaybackOrder = PlaybackOrder.Shuffle,
GuideMode = GuideMode.Normal,
Watermarks = [],
GraphicsElements = []
}
],
Playouts = [],
ProgramScheduleAlternates = []
};
context.Channels.Add(channel);
context.ProgramSchedules.Add(schedule);
await context.SaveChangesAsync();
return (channel.Id, schedule.Id);
}
}
@@ -0,0 +1,188 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Integration;
[TestFixture]
public class ScheduleItemTptIntegrationTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task AddProgramScheduleItem_Should_Create_Tpt_Subtype_Row_For_Each_PlayoutMode()
{
int scheduleId = await SeedSchedule();
var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker);
foreach (PlayoutMode playoutMode in new[]
{
PlayoutMode.One,
PlayoutMode.Multiple,
PlayoutMode.Flood,
PlayoutMode.Duration
})
{
Either<BaseError, ProgramScheduleItemViewModel> result =
await handler.Handle(MakeAdd(scheduleId, playoutMode), CancellationToken.None);
result.IsRight.ShouldBeTrue();
}
await using TvContext context = _db.CreateContext();
(await CountRows(context, "ProgramScheduleOneItem")).ShouldBe(1);
(await CountRows(context, "ProgramScheduleMultipleItem")).ShouldBe(1);
(await CountRows(context, "ProgramScheduleFloodItem")).ShouldBe(1);
(await CountRows(context, "ProgramScheduleDurationItem")).ShouldBe(1);
}
[Test]
public async Task ReplaceProgramScheduleItems_Should_Create_Tpt_Subtype_Rows_For_Replaced_Items()
{
int scheduleId = await SeedSchedule();
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
await handler.Handle(
new ReplaceProgramScheduleItems(
scheduleId,
[
MakeReplace(0, PlayoutMode.One),
MakeReplace(1, PlayoutMode.Multiple),
MakeReplace(2, PlayoutMode.Flood),
MakeReplace(3, PlayoutMode.Duration)
]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = _db.CreateContext();
(await CountRows(context, "ProgramScheduleOneItem")).ShouldBe(1);
(await CountRows(context, "ProgramScheduleMultipleItem")).ShouldBe(1);
(await CountRows(context, "ProgramScheduleFloodItem")).ShouldBe(1);
(await CountRows(context, "ProgramScheduleDurationItem")).ShouldBe(1);
}
private async Task<int> SeedSchedule()
{
await using TvContext context = _db.CreateContext();
var schedule = new ProgramSchedule
{
Name = "Integration",
Items = [],
Playouts = [],
ProgramScheduleAlternates = []
};
context.ProgramSchedules.Add(schedule);
await context.SaveChangesAsync();
return schedule.Id;
}
private static AddProgramScheduleItem MakeAdd(int scheduleId, PlayoutMode playoutMode) =>
new(
scheduleId,
StartType.Dynamic,
StartTime: null,
FixedStartTimeBehavior: null,
playoutMode,
CollectionType.SearchQuery,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: null,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null,
SearchTitle: "News",
SearchQuery: "news",
PlaybackOrder: PlaybackOrder.Shuffle,
MarathonGroupBy: MarathonGroupBy.None,
MarathonShuffleGroups: false,
MarathonShuffleItems: false,
MarathonBatchSize: null,
FillWithGroupMode: FillWithGroupMode.None,
MultipleMode: MultipleMode.Count,
MultipleCount: "2",
PlayoutDuration: TimeSpan.FromMinutes(30),
TailMode: TailMode.None,
DiscardToFillAttempts: 3,
CustomTitle: null,
GuideMode: GuideMode.Normal,
PreRollFillerId: null,
MidRollFillerId: null,
PostRollFillerId: null,
TailFillerId: null,
FallbackFillerId: null,
WatermarkIds: [],
GraphicsElementIds: [],
PreferredAudioLanguageCode: null,
PreferredAudioTitle: null,
PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
private static ReplaceProgramScheduleItem MakeReplace(int index, PlayoutMode playoutMode)
{
AddProgramScheduleItem add = MakeAdd(1, playoutMode);
return new ReplaceProgramScheduleItem(
index,
add.StartType,
add.StartTime,
add.FixedStartTimeBehavior,
add.PlayoutMode,
add.CollectionType,
add.CollectionId,
add.MultiCollectionId,
add.SmartCollectionId,
add.RerunCollectionId,
add.MediaItemId,
add.PlaylistId,
add.SearchTitle,
add.SearchQuery,
add.PlaybackOrder,
add.MarathonGroupBy,
add.MarathonShuffleGroups,
add.MarathonShuffleItems,
add.MarathonBatchSize,
add.FillWithGroupMode,
add.MultipleMode,
add.MultipleCount,
add.PlayoutDuration,
add.TailMode,
add.DiscardToFillAttempts,
add.CustomTitle,
add.GuideMode,
add.PreRollFillerId,
add.MidRollFillerId,
add.PostRollFillerId,
add.TailFillerId,
add.FallbackFillerId,
add.WatermarkIds,
add.GraphicsElementIds,
add.PreferredAudioLanguageCode,
add.PreferredAudioTitle,
add.PreferredSubtitleLanguageCode,
add.SubtitleMode);
}
private static Task<int> CountRows(TvContext context, string tableName) =>
context.Database.SqlQueryRaw<int>($"SELECT COUNT(*) AS Value FROM {tableName}").SingleAsync();
}

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