Compare commits

..
Author SHA1 Message Date
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
96 changed files with 7306 additions and 405 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.
@@ -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 [];
}
}
}
@@ -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."));
}
}
@@ -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,
@@ -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,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,33 @@
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)
{
public static PlayoutResponseModel From(
int id,
PlayoutScheduleKind scheduleKind,
string channelName,
string channelNumber,
ChannelPlayoutMode playoutMode,
string scheduleName,
string scheduleFile,
TimeSpan? dailyRebuildTime) =>
new(
id,
scheduleKind,
channelName,
channelNumber,
playoutMode,
scheduleName,
scheduleFile,
dailyRebuildTime);
}
@@ -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);
}
@@ -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,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,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,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));
}
}
@@ -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");
}
}
}
@@ -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();
}
}
@@ -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,35 @@ 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/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,197 @@
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.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.GetById), "GET", "/api/playouts/{id:int}");
ShouldHaveActionRoute(nameof(PlayoutController.Create), "POST", "/api/playouts");
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");
}
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);
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,383 @@
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)];
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily")));
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(items);
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(items);
}
[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();
}
@@ -8,7 +8,6 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -16,11 +15,6 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
// Apply the optional API-key control at the controller level so that EVERY mutating action
// (including future ones) is covered by default; the filter no-ops on read methods (GET) and
// when Api:WriteKey is unset, preserving the open LAN behavior. This is fail-safe: a developer
// adding a new write endpoint here cannot accidentally leave it unauthenticated.
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator)
{
[HttpGet("/api/channels")]
@@ -3,7 +3,6 @@ using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -11,7 +10,6 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class CollectionController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/collections")]
@@ -1,44 +1,104 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class FFmpegProfileController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/ffmpeg/profiles", Name="GetFFmpegProfiles")]
[HttpGet("/api/ffmpeg/profiles", Name = "GetFFmpegProfiles")]
[Tags("FFmpeg Profiles")]
[EndpointSummary("Get all FFmpeg profiles")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<FFmpegFullProfileResponseModel>), StatusCodes.Status200OK)]
public async Task<List<FFmpegFullProfileResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFFmpegProfilesForApi(), cancellationToken);
[HttpPost("/api/ffmpeg/profiles/new", Name="CreateFFmpegProfile")]
[HttpGet("/api/ffmpeg/profiles/{id:int}", Name = "GetFFmpegProfileById")]
[Tags("FFmpeg Profiles")]
[EndpointSummary("Get an FFmpeg profile by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(FFmpegFullProfileResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<FFmpegFullProfileResponseModel> result =
await mediator.Send(new GetFFmpegFullProfileByIdForApi(id), cancellationToken);
return result.ToGetResult();
}
[HttpPost("/api/ffmpeg/profiles", Name = "CreateFFmpegProfile")]
[Tags("FFmpeg Profiles")]
[EndpointSummary("Create an FFmpeg profile")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(FFmpegFullProfileResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> AddOne(
[Required] [FromBody]
CreateFFmpegProfile request,
CreateFFmpegProfileRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateFFmpegProfileResult> result = await mediator.Send(request, cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
Either<BaseError, CreateFFmpegProfileResult> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async created =>
{
Option<FFmpegFullProfileResponseModel> profile =
await mediator.Send(new GetFFmpegFullProfileByIdForApi(created.FFmpegProfileId), cancellationToken);
return profile.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/ffmpeg/profiles/{vm.Id}", vm),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpPut("/api/ffmpeg/profiles/update", Name="UpdateFFmpegProfile")]
[HttpPut("/api/ffmpeg/profiles/{id:int}", Name = "UpdateFFmpegProfile")]
[Tags("FFmpeg Profiles")]
[EndpointSummary("Update an FFmpeg profile")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(FFmpegFullProfileResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateOne(
int id,
[Required] [FromBody]
UpdateFFmpegProfile request,
UpdateFFmpegProfileRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, UpdateFFmpegProfileResult> result = await mediator.Send(request, cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
Either<BaseError, UpdateFFmpegProfileResult> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async updated =>
{
Option<FFmpegFullProfileResponseModel> profile =
await mediator.Send(new GetFFmpegFullProfileByIdForApi(updated.FFmpegProfileId), cancellationToken);
return profile.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpDelete("/api/ffmpeg/delete/{id:int}", Name="DeleteFFmpegProfile")]
[HttpDelete("/api/ffmpeg/profiles/{id:int}", Name = "DeleteFFmpegProfile")]
[Tags("FFmpeg Profiles")]
[EndpointSummary("Delete an FFmpeg profile")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> DeleteProfileAsync(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteFFmpegProfile(id), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Conflict(error.ToString()));
return result.ToDeletedResult();
}
}
@@ -0,0 +1,19 @@
using ErsatzTV.Application.Filler;
using ErsatzTV.Core.Api.Filler;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class FillerPresetController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/filler-presets", Name = "GetFillerPresets")]
[Tags("Filler Presets")]
[EndpointSummary("Get all filler presets")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<FillerPresetResponseModel>), StatusCodes.Status200OK)]
public async Task<List<FillerPresetResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken);
}
@@ -0,0 +1,19 @@
using ErsatzTV.Application.Graphics;
using ErsatzTV.Core.Api.Graphics;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class GraphicsElementController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/graphics-elements", Name = "GetGraphicsElements")]
[Tags("Graphics Elements")]
[EndpointSummary("Get all graphics elements")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<GraphicsElementResponseModel>), StatusCodes.Status200OK)]
public async Task<List<GraphicsElementResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken);
}
@@ -0,0 +1,18 @@
using ErsatzTV.Application.Health;
using ErsatzTV.Core.Api.Health;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class HealthController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/health", Name = "GetHealthChecks")]
[Tags("Health")]
[EndpointSummary("Get health check results")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<HealthCheckResponseModel>), StatusCodes.Status200OK)]
public async Task<List<HealthCheckResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllHealthCheckResultsForApi(), cancellationToken);
}
@@ -0,0 +1,75 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Playouts;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class PlayoutController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")]
[Tags("Playouts")]
[EndpointSummary("Get a playout by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> result = await mediator.Send(new GetPlayoutById(id), cancellationToken);
return result.Map(ToResponse).ToGetResult();
}
[HttpPost("/api/playouts")]
[Tags("Playouts")]
[EndpointSummary("Create a classic playout")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody] CreatePlayoutRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreatePlayoutResponse> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async created =>
{
Option<PlayoutNameViewModel> playout =
await mediator.Send(new GetPlayoutById(created.PlayoutId), cancellationToken);
return playout.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/playouts/{vm.PlayoutId}", ToResponse(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpDelete("/api/playouts/{id:int}")]
[Tags("Playouts")]
[EndpointSummary("Delete a playout")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeletePlayout(id), cancellationToken);
return result.ToDeletedResult();
}
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) =>
PlayoutResponseModel.From(
vm.PlayoutId,
vm.ScheduleKind,
vm.ChannelName,
vm.ChannelNumber,
vm.PlayoutMode,
vm.ScheduleName,
vm.ScheduleFile,
vm.DbDailyRebuildTime);
}
@@ -0,0 +1,71 @@
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateFFmpegProfileRequest(
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
string VaapiDevice,
int? QsvExtraHardwareFrames,
int ResolutionId,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
bool AllowBFrames,
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
int VideoBufferSize,
FFmpegProfileTonemapAlgorithm TonemapAlgorithm,
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo)
{
public CreateFFmpegProfile ToCommand() =>
new(
Name,
ThreadCount,
NormalizeAudio,
NormalizeVideo,
HardwareAcceleration,
VaapiDisplay,
VaapiDriver,
VaapiDevice,
QsvExtraHardwareFrames,
ResolutionId,
ScalingBehavior,
PadMode,
VideoFormat,
VideoProfile,
VideoPreset,
AllowBFrames,
BitDepth,
VideoBitrate,
VideoBufferSize,
TonemapAlgorithm,
AudioFormat,
AudioBitrate,
AudioBufferSize,
NormalizeLoudnessMode,
TargetLoudness,
AudioChannels,
AudioSampleRate,
NormalizeFramerate,
NormalizeColors,
DeinterlaceVideo);
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.Playouts;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreatePlayoutRequest(int ChannelId, int ProgramScheduleId)
{
public CreateClassicPlayout ToCommand() => new(ChannelId, ProgramScheduleId);
}
@@ -0,0 +1,22 @@
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateScheduleRequest(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
public CreateProgramSchedule ToCreateCommand() =>
new(
Name,
KeepMultiPartEpisodesTogether,
TreatCollectionsAsShows,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
}
@@ -0,0 +1,11 @@
using ErsatzTV.Application.ProgramSchedules;
namespace ErsatzTV.Controllers.Api.Requests;
public record ReplaceScheduleItemsRequest(List<ScheduleItemRequest> Items)
{
public ReplaceProgramScheduleItems ToCommand(int scheduleId) =>
new(
scheduleId,
(Items ?? []).Select((item, index) => item.ToReplaceCommand(index)).ToList());
}
@@ -0,0 +1,127 @@
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record ScheduleItemRequest(
StartType StartType,
TimeSpan? StartTime,
FixedStartTimeBehavior? FixedStartTimeBehavior,
PlayoutMode PlayoutMode,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId,
string SearchTitle,
string SearchQuery,
PlaybackOrder PlaybackOrder,
MarathonGroupBy MarathonGroupBy,
bool MarathonShuffleGroups,
bool MarathonShuffleItems,
int? MarathonBatchSize,
FillWithGroupMode FillWithGroupMode,
MultipleMode MultipleMode,
string MultipleCount,
TimeSpan? PlayoutDuration,
TailMode TailMode,
int? DiscardToFillAttempts,
string CustomTitle,
GuideMode GuideMode,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
int? TailFillerId,
int? FallbackFillerId,
List<int> WatermarkIds,
List<int> GraphicsElementIds,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode)
{
public AddProgramScheduleItem ToAddCommand(int scheduleId) =>
new(
scheduleId,
StartType,
StartTime,
FixedStartTimeBehavior,
PlayoutMode,
CollectionType,
CollectionId,
MultiCollectionId,
SmartCollectionId,
RerunCollectionId,
MediaItemId,
PlaylistId,
SearchTitle,
SearchQuery,
PlaybackOrder,
MarathonGroupBy,
MarathonShuffleGroups,
MarathonShuffleItems,
MarathonBatchSize,
FillWithGroupMode,
MultipleMode,
MultipleCount,
PlayoutDuration,
TailMode,
DiscardToFillAttempts,
CustomTitle,
GuideMode,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
TailFillerId,
FallbackFillerId,
WatermarkIds ?? [],
GraphicsElementIds ?? [],
PreferredAudioLanguageCode,
PreferredAudioTitle,
PreferredSubtitleLanguageCode,
SubtitleMode);
public ReplaceProgramScheduleItem ToReplaceCommand(int index) =>
new(
index,
StartType,
StartTime,
FixedStartTimeBehavior,
PlayoutMode,
CollectionType,
CollectionId,
MultiCollectionId,
SmartCollectionId,
RerunCollectionId,
MediaItemId,
PlaylistId,
SearchTitle,
SearchQuery,
PlaybackOrder,
MarathonGroupBy,
MarathonShuffleGroups,
MarathonShuffleItems,
MarathonBatchSize,
FillWithGroupMode,
MultipleMode,
MultipleCount,
PlayoutDuration,
TailMode,
DiscardToFillAttempts,
CustomTitle,
GuideMode,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
TailFillerId,
FallbackFillerId,
WatermarkIds ?? [],
GraphicsElementIds ?? [],
PreferredAudioLanguageCode,
PreferredAudioTitle,
PreferredSubtitleLanguageCode,
SubtitleMode);
}
@@ -0,0 +1,72 @@
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateFFmpegProfileRequest(
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
string VaapiDevice,
int? QsvExtraHardwareFrames,
int ResolutionId,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
bool AllowBFrames,
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
int VideoBufferSize,
FFmpegProfileTonemapAlgorithm TonemapAlgorithm,
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo)
{
public UpdateFFmpegProfile ToCommand(int id) =>
new(
id,
Name,
ThreadCount,
NormalizeAudio,
NormalizeVideo,
HardwareAcceleration,
VaapiDisplay,
VaapiDriver,
VaapiDevice,
QsvExtraHardwareFrames,
ResolutionId,
ScalingBehavior,
PadMode,
VideoFormat,
VideoProfile,
VideoPreset,
AllowBFrames,
BitDepth,
VideoBitrate,
VideoBufferSize,
TonemapAlgorithm,
AudioFormat,
AudioBitrate,
AudioBufferSize,
NormalizeLoudnessMode,
TargetLoudness,
AudioChannels,
AudioSampleRate,
NormalizeFramerate,
NormalizeColors,
DeinterlaceVideo);
}
@@ -0,0 +1,23 @@
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateScheduleRequest(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
public UpdateProgramSchedule ToCommand(int id) =>
new(
id,
Name,
KeepMultiPartEpisodesTogether,
TreatCollectionsAsShows,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
}
@@ -2,12 +2,14 @@ using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Search;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Filters;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[SkipApiKeyAuthorization]
[Route("api/scan/{scanId:guid}")]
public class ScannerController(
IScannerProxyService scannerProxyService,
@@ -0,0 +1,165 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ScheduleController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/schedules")]
[Tags("Schedules")]
[EndpointSummary("Get all schedules")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<ProgramScheduleViewModel>), StatusCodes.Status200OK)]
public async Task<List<ProgramScheduleViewModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllProgramSchedules(), cancellationToken);
[HttpGet("/api/schedules/{id:int}", Name = "GetScheduleById")]
[Tags("Schedules")]
[EndpointSummary("Get a schedule by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ProgramScheduleViewModel> result = await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
return result.ToGetResult();
}
[HttpPost("/api/schedules")]
[Tags("Schedules")]
[EndpointSummary("Create a schedule")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateScheduleRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateProgramScheduleResult> result =
await mediator.Send(request.ToCreateCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async created =>
{
Option<ProgramScheduleViewModel> schedule =
await mediator.Send(new GetProgramScheduleById(created.ProgramScheduleId), cancellationToken);
return schedule.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/schedules/{vm.Id}", vm),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpPut("/api/schedules/{id:int}")]
[Tags("Schedules")]
[EndpointSummary("Update a schedule")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateScheduleRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, UpdateProgramScheduleResult> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
Option<ProgramScheduleViewModel> schedule =
await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
return schedule.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpDelete("/api/schedules/{id:int}")]
[Tags("Schedules")]
[EndpointSummary("Delete a schedule")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteProgramSchedule(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpGet("/api/schedules/{id:int}/items")]
[Tags("Schedules")]
[EndpointSummary("Get schedule items")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<ProgramScheduleItemViewModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
{
Option<ProgramScheduleViewModel> schedule = await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
if (schedule.IsNone)
{
return ApiResults.NotFoundProblem();
}
List<ProgramScheduleItemViewModel> items =
await mediator.Send(new GetProgramScheduleItems(id), cancellationToken);
return new OkObjectResult(items);
}
[HttpPost("/api/schedules/{id:int}/items")]
[Tags("Schedules")]
[EndpointSummary("Add a schedule item")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleItemViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> AddItem(
int id,
[Required] [FromBody] ScheduleItemRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, ProgramScheduleItemViewModel> result =
await mediator.Send(request.ToAddCommand(id), cancellationToken);
return result.ToCreatedResult(item => $"/api/schedules/{id}/items/{item.Id}", item => item);
}
[HttpPut("/api/schedules/{id:int}/items")]
[Tags("Schedules")]
[EndpointSummary("Replace schedule items")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(IEnumerable<ProgramScheduleItemViewModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceItems(
int id,
[Required] [FromBody] ReplaceScheduleItemsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToUpdatedResult();
}
[HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")]
[Tags("Schedules")]
[EndpointSummary("Delete a schedule item")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> DeleteItem(int id, int itemId, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result =
await mediator.Send(new DeleteProgramScheduleItem(id, itemId), cancellationToken);
return result.ToDeletedResult();
}
}
@@ -4,7 +4,6 @@ using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -12,7 +11,6 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class SmartCollectionController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/smart-collections")]
@@ -0,0 +1,19 @@
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Api.Watermarks;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class WatermarkController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/watermarks", Name = "GetWatermarks")]
[Tags("Watermarks")]
[EndpointSummary("Get all watermarks")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<WatermarkResponseModel>), StatusCodes.Status200OK)]
public async Task<List<WatermarkResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllWatermarksForApi(), cancellationToken);
}
+35 -5
View File
@@ -6,12 +6,12 @@ using Microsoft.Extensions.Primitives;
namespace ErsatzTV.Filters;
/// <summary>
/// Optional API-key authorization for mutating JSON API endpoints (slice #2a).
/// Optional API-key authorization for mutating JSON API endpoints.
/// Reads the configured key from <c>Api:WriteKey</c>. When that key is empty the filter is
/// a no-op (preserving the current open LAN behavior); when it is set, mutating requests
/// (POST/PUT/PATCH/DELETE) must present a matching <c>X-Api-Key</c> header or receive 401.
/// This is fully independent of <see cref="JwtHelper" /> and only applies to the actions it
/// decorates — it never affects /iptv/* or any read endpoint.
/// under <c>/api/*</c> (POST/PUT/PATCH/DELETE) must present a matching <c>X-Api-Key</c>
/// header or receive 401. This is fully independent of <see cref="JwtHelper" /> and never
/// affects /iptv/* or any read endpoint.
/// </summary>
public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthorizationFilter
{
@@ -20,6 +20,11 @@ public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthoriz
public void OnAuthorization(AuthorizationFilterContext context)
{
if (ShouldSkipApiKeyAuthorization(context))
{
return;
}
string configuredKey = configuration[ConfigurationKey];
// empty key => API-key auth disabled, endpoint is open
@@ -42,7 +47,32 @@ public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthoriz
if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out StringValues provided)
|| !string.Equals(provided.ToString(), configuredKey, StringComparison.Ordinal))
{
context.Result = new UnauthorizedResult();
context.Result = new UnauthorizedObjectResult(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = "A valid API key is required for write requests."
});
}
}
private static bool ShouldSkipApiKeyAuthorization(AuthorizationFilterContext context)
{
if (context.Filters.OfType<SkipApiKeyAuthorizationAttribute>().Any()
|| context.ActionDescriptor.EndpointMetadata.OfType<SkipApiKeyAuthorizationAttribute>().Any())
{
return true;
}
if (!context.HttpContext.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
{
return true;
}
string method = context.HttpContext.Request.Method;
return !HttpMethods.IsPost(method)
&& !HttpMethods.IsPut(method)
&& !HttpMethods.IsPatch(method)
&& !HttpMethods.IsDelete(method);
}
}
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace ErsatzTV.Filters;
/// <summary>
/// Marks an internal API endpoint as exempt from global API-key write authorization.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class SkipApiKeyAuthorizationAttribute : Attribute, IFilterMetadata;
+42 -1
View File
@@ -4,6 +4,7 @@ using System.IO.Abstractions;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading.Channels;
using BlazorSortable;
@@ -118,6 +119,35 @@ public class Startup
private IWebHostEnvironment CurrentEnvironment { get; }
private static void UseStringEnumSchemas(OpenApiDocument document)
{
if (document.Components?.Schemas is null)
{
return;
}
Dictionary<string, Type> enumTypes = typeof(Core.Domain.PlayoutMode).Assembly.GetTypes()
.Where(type => type.IsEnum)
.GroupBy(type => type.Name)
.ToDictionary(group => group.Key, group => group.First());
foreach ((string schemaName, IOpenApiSchema schema) in document.Components.Schemas)
{
if (schema is not OpenApiSchema openApiSchema ||
!enumTypes.TryGetValue(schemaName, out Type enumType))
{
continue;
}
openApiSchema.Type = JsonSchemaType.String;
openApiSchema.Format = null;
openApiSchema.Enum = Enum.GetNames(enumType)
.Select(name => JsonValue.Create(name))
.Cast<JsonNode>()
.ToList();
}
}
[SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments")]
public void ConfigureServices(IServiceCollection services)
{
@@ -133,7 +163,17 @@ public class Startup
services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(FileSystemLayout.DataProtectionFolder));
services.AddOpenApi("v1", options => { options.ShouldInclude += a => a.GroupName == "general"; });
services.AddOpenApi(
"v1",
options =>
{
options.ShouldInclude += a => a.GroupName == "general";
options.AddDocumentTransformer((document, _, _) =>
{
UseStringEnumSchemas(document);
return Task.CompletedTask;
});
});
services.AddOpenApi(
"scripted-schedule-tagged",
@@ -298,6 +338,7 @@ public class Startup
options.OutputFormatters.Insert(0, new ChannelGuideOutputFormatter());
options.OutputFormatters.Insert(0, new DeviceXmlOutputFormatter());
options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter());
options.Filters.AddService<ApiKeyAuthorizationFilter>();
})
.AddNewtonsoftJson(opt =>
{
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-amd64 AS dotnet-runtime
FROM --platform=linux/amd64 192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1 AS runtime-base
FROM --platform=linux/amd64 192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 python3-pip && \
@@ -40,7 +40,7 @@ COPY ErsatzTV.Infrastructure/*.csproj ./ErsatzTV.Infrastructure/
COPY ErsatzTV.Infrastructure.Sqlite/*.csproj ./ErsatzTV.Infrastructure.Sqlite/
COPY ErsatzTV.Infrastructure.MySql/*.csproj ./ErsatzTV.Infrastructure.MySql/
COPY ErsatzTV.Scanner/*.csproj ./ErsatzTV.Scanner/
RUN dotnet restore -r linux-x64 ErsatzTV/
RUN dotnet restore -r linux-x64 ErsatzTV/ErsatzTV.csproj
# copy everything else and build app
COPY ErsatzTV/. ./ErsatzTV/
+4 -3
View File
@@ -1,14 +1,15 @@
FROM ghcr.io/ersatztv/ersatztv-ffmpeg:7.1.1
FROM 192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2
RUN apt-get update && apt-get install -y ca-certificates gnupg mkvtoolnix && \
curl -L https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh && \
chmod +x ./dotnet-install.sh && \
./dotnet-install.sh --channel 9.0
./dotnet-install.sh --channel 10.0
ENV DOTNET_ROOT="/root/.dotnet"
ENV PATH="$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools"
WORKDIR /source
# copy csproj and restore as distinct layers
COPY *.sln .
COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json .editorconfig ./
COPY artwork/* ./artwork/
COPY ErsatzTV/*.csproj ./ErsatzTV/
COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/
@@ -20,7 +21,7 @@ COPY ErsatzTV.Infrastructure.Sqlite/*.csproj ./ErsatzTV.Infrastructure.Sqlite/
COPY ErsatzTV.Infrastructure.MySql/*.csproj ./ErsatzTV.Infrastructure.MySql/
COPY ErsatzTV.Scanner/*.csproj ./ErsatzTV.Scanner/
COPY ErsatzTV.Scanner.Tests/*.csproj ./ErsatzTV.Scanner.Tests/
RUN dotnet restore -r linux-x64 ErsatzTV/
RUN dotnet restore -r linux-x64 ErsatzTV/ErsatzTV.csproj
# copy everything else and build app
COPY ErsatzTV/. ./ErsatzTV/
+3 -3
View File
@@ -75,9 +75,9 @@ the image build.
## Dockerfile notes (`docker/Dockerfile`)
- Base image: **`192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1`** (our Gitea fork of
the archived `ghcr.io/ersatztv/ersatztv-ffmpeg`). FFmpeg 8 upgrade is backlogged:
base image → ersatztv-ffmpeg#4, app-side compat ersatztv#9.
- Base image: **`192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2`** (our Gitea fork of
the archived `ghcr.io/ersatztv/ersatztv-ffmpeg`). FFmpeg 8 base image work landed in
ersatztv-ffmpeg#4; app-side compatibility work landed in ersatztv#9.
- Copies `Directory.Build.props`, `Directory.Build.targets`, `Directory.Packages.props`,
`global.json`, `.editorconfig` before `dotnet restore` so the image build uses the same
MSBuild config, central package versions, SDK pin, and analyzer severities as local/CI
+1 -1
View File
@@ -24,7 +24,7 @@ We diverge freely from upstream's final state. There is no upstream to merge fro
### Docker Base Images
- .NET SDK/runtime images (`mcr.microsoft.com/dotnet/sdk:10.0-noble-amd64`) — update when .NET patches ship.
- FFmpeg image: forked separately at [timothy/ersatztv-ffmpeg](http://192.168.1.95:3000/timothy/ersatztv-ffmpeg). Currently `192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1`. The main Dockerfile still references the upstream `ghcr.io` image and needs updating.
- FFmpeg image: forked separately at [timothy/ersatztv-ffmpeg](http://192.168.1.95:3000/timothy/ersatztv-ffmpeg). Currently `192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2`.
### CVE Response
+17 -6
View File
@@ -44,8 +44,10 @@ A few **format/UX checks are page-only** (FluentValidation in `.razor` / `Valida
- **OpenAPI already wired**: `AddOpenApi("v1")` + **Scalar UI at `/docs`** (`Startup.cs:136`, `:664`); endpoints opt in via `[EndpointGroupName("general")]`.
- `.ToActionResult()` extensions map: `Either` Left→**400**, Right→**200**; `Option` None→**404**, Some→**200**; `Validation` Failure→**400**. **No 201/422 today.**
### 2.5 Two existing CRUD controllers are non-idiomatic → standardize
`FFmpegProfileController` and `SmartCollectionController` use verb-in-path (`/new`, `/update`, `/delete/{id}`). Per the modernization decision, retrofit them to idiomatic REST as we work the related slices, gated by characterization tests.
### 2.5 Existing CRUD controllers are standardized
`SmartCollectionController` and `FFmpegProfileController` previously used verb-in-path routes (`/new`,
`/update`, `/delete/{id}`). They have been retrofitted to idiomatic REST routes as part of the REST #2
slice work, gated by characterization tests and the shared `ProblemDetails` error contract.
### 2.6 Auth today
A **JWT bearer scheme** exists (`JwtHelper`, `JwtOnlyScheme` policy, `access_token` query-param support, 1-day tokens) but is wired **only to `IptvController`** via `ConditionalIptvAuthorizeFilter` (enforced only when `JWT:IssuerSigningKey` is set). `/api/*` currently has **no auth**; CORS is **AllowAll**.
@@ -98,11 +100,17 @@ The current `.ToActionResult()` only yields 200/400/404, so we add **richer mapp
Reuse handler validation. Port the page-only checks in §2.3 into handlers so the API reaches parity with the Blazor UI. No validation logic in controllers.
### 3.5 Auth — dedicated API key for writes (decoupled from IPTV)
**Mechanism:** a dedicated **API key** for mutations — a new config key (e.g. `Api__WriteKey`) checked by a filter applied **only to POST/PUT/DELETE on `/api/*`**. Enforced only when the key is configured (LAN-open default preserved); reads and all `/iptv/*` stay open.
**Mechanism:** a dedicated **API key** for mutations — `Api:WriteKey` / `Api__WriteKey`,
checked against the `X-Api-Key` request header by a global MVC filter. When the key is configured,
all mutating `/api/*` requests (`POST`/`PUT`/`PATCH`/`DELETE`) require the header; when the key is
unset, the LAN-open default is preserved. Reads and all `/iptv/*` routes stay open. The scanner
callback controller is the designed exemption because scanner child processes call
`/api/scan/{scanId}/...` without `X-Api-Key`; any future exemption must be explicit via
`[SkipApiKeyAuthorization]`.
**Why not reuse the JWT scheme (important):** JWT is gated by a single global toggle, `JwtHelper.IsEnabled``JWT:IssuerSigningKey` (`Startup.cs:166`). That **same toggle also gates the IPTV endpoints** (`/iptv/channels.m3u`, `/iptv/xmltv.xml`, streams — `ConditionalIptvAuthorizeFilter:18`) which **Jellyfin and Dispatcharr consume**. Enabling JWT to protect writes would force token auth onto those media feeds — and JWT tokens **expire in 1 day** (`JwtHelper.cs:27`), unsuitable for a standing tuner URL. A dedicated API key **decouples write-auth from media-consumer auth**: turning it on changes **nothing** for Jellyfin/Dispatcharr.
**Properties:** long-lived credential (no 1-day churn), fit for MCP / new-UI write clients; net-new but small (one filter + one config key); a standard machine-API pattern.
**Properties:** long-lived credential (no 1-day churn), fit for MCP / new-UI write clients; net-new but small (one global filter + one config key); a standard machine-API pattern.
**Backlog:** tighten CORS for mutation routes if writes are exposed beyond LAN.
@@ -116,7 +124,9 @@ New endpoints carry `[EndpointGroupName("general")]` → appear in the existing
## 4. Standardization scope
- Retrofit `FFmpegProfileController` + `SmartCollectionController` to idiomatic REST, each gated by **characterization tests** (capture current behavior → change → prove green).
- Retrofit `FFmpegProfileController` + `SmartCollectionController` to idiomatic REST, each gated by **characterization tests** (capture current behavior → change → prove green). Completed in #35/#38.
- CORS review in #38: the app still uses the existing global `AllowAll` policy. REST mutations are protected by the optional API-key write filter, and changing CORS defaults would be an operational exposure decision rather than an API-shape cleanup. Keep CORS tightening as backlog if write APIs are exposed beyond the LAN.
- Sweep result in #38: REST #2 CRUD controllers now use idiomatic routes. Older operational endpoints such as `/api/libraries/{id}/scan`, `/api/maintenance/empty_trash`, and `/api/maintenance/clean_artwork` remain outside the REST #2 CRUD standardization scope.
- Opportunistic-fix policy: backlog/document unrelated issues found en route; fix-in-place only when limited-scope + useful-now, or when deferring would force rework of the new code.
## 5. Increment plan (sub-issues under #2 as tracker)
@@ -127,7 +137,7 @@ One slice = one branch = one PR. PR runs `test` + `migrations` (both required);
- **#2b (#35) — Collections** CRUD + add/remove items; **retrofit `SmartCollectionController`** to idiomatic (characterization tests first).
- **#2c (#36) — Schedules** CRUD + schedule items (**TPT-heavy** — the one to budget for). Design the item DTO around the `PlayoutMode` discriminator (One/Multiple/Flood/Duration); reuse `AddProgramScheduleItem` / `ReplaceProgramScheduleItems`. Integration test proving the correct TPT subtype rows are written.
- **#2d (#37) — Playouts** create (Classic + 4 kinds via discriminated DTO) / delete; keep existing reset. Validation already strong.
- **#2e (#38) — Standardization cleanup.** Retrofit `FFmpegProfileController`; OpenAPI/doc polish; CORS review for mutations; sweep for any other non-idiomatic `/api` endpoints; fold in backlog items gathered during #2a#2d.
- **#2e (#38) — Standardization cleanup.** Retrofit `FFmpegProfileController`; OpenAPI/doc polish; CORS review for mutations; sweep for any other non-idiomatic `/api` endpoints; fold in backlog items gathered during #2a#2d. Completed: FFmpeg profile CRUD now uses `/api/ffmpeg/profiles[/{id}]`, request DTOs, API-key write filtering, `ProblemDetails` 404/422 responses, and generated OpenAPI metadata. Deferred: configurable CORS tightening and legacy operational endpoint reshaping.
**Sequencing:** #2a first (sets every convention the others copy), then #2b#2d in parallel-able order, #2e last. Each slice ships its own read endpoints so the new UI gains coverage incrementally.
@@ -140,6 +150,7 @@ One slice = one branch = one PR. PR runs `test` + `migrations` (both required);
## 7. Open items / backlog seeds
- CORS tightening for mutation routes (if exposed beyond LAN).
- Legacy operational `/api` command routes (`libraries/*/scan`, maintenance actions) still use older action-style names. They are outside REST #2 CRUD and should be handled in a separate operational API cleanup if needed.
- List-endpoint filtering/sorting/pagination depth (new-UI driven).
- API-key provisioning UX for MCP/UI write clients (how a caller obtains/sets `Api__WriteKey`).
- Decide whether `NotFoundError` typed-error becomes a repo-wide convention or stays API-local.