Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
922bec4b24 | ||
|
|
272174ee75 | ||
|
|
29407f637b | ||
|
|
83c9122b6f | ||
|
|
86f07594e4 | ||
|
|
446f50763a | ||
|
|
0eaedb9cf6 | ||
|
|
8912686a47 | ||
|
|
d1dfe6eb5a | ||
|
|
a2d2f5930c | ||
|
|
45ade1fd78 | ||
|
|
e170b2dc3c | ||
|
|
183f6c5d2a | ||
|
|
5560790bcb | ||
|
|
f25cfdb593 | ||
|
|
527332a3ac | ||
|
|
cd20dfb2aa | ||
|
|
8e6379a51b | ||
|
|
4aec234e20 | ||
|
|
861827dcbc | ||
|
|
30ecd9a8a7 | ||
|
|
b53b06c615 | ||
|
|
4f8b3042db | ||
|
|
b552905799 | ||
|
|
0d101e79b1 |
@@ -6,6 +6,9 @@ project.lock.json
|
||||
|
||||
# Claude Code
|
||||
.mcp/
|
||||
.mcp.json
|
||||
.agents/
|
||||
plugins/
|
||||
nupkg/
|
||||
|
||||
# Visual Studio Code
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
|
||||
new(fillerPreset.Id, fillerPreset.Name);
|
||||
|
||||
internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) =>
|
||||
new(
|
||||
fillerPreset.Id,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
|
||||
@@ -0,0 +1,22 @@
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Filler.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public class GetAllFillerPresetsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllFillerPresetsForApi, List<FillerPresetResponseModel>>
|
||||
{
|
||||
public async Task<List<FillerPresetResponseModel>> Handle(
|
||||
GetAllFillerPresetsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<FillerPreset> fillerPresets = await dbContext.FillerPresets
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
return fillerPresets.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public record GetAllGraphicsElementsForApi : IRequest<List<GraphicsElementResponseModel>>;
|
||||
@@ -0,0 +1,27 @@
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Graphics.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllGraphicsElementsForApi, List<GraphicsElementResponseModel>>
|
||||
{
|
||||
public async Task<List<GraphicsElementResponseModel>> Handle(
|
||||
GetAllGraphicsElementsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<GraphicsElement> graphicsElements = await dbContext.GraphicsElements
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
return graphicsElements
|
||||
.Map(ProjectToViewModel)
|
||||
.OrderBy(e => e.Name == e.FileName)
|
||||
.ThenBy(e => e.Name)
|
||||
.Select(vm => new GraphicsElementResponseModel(vm.Id, vm.Name))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
using ErsatzTV.Core.Health;
|
||||
|
||||
namespace ErsatzTV.Application.Health;
|
||||
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static HealthCheckResponseModel ProjectToResponseModel(HealthCheckResult result) =>
|
||||
new(
|
||||
result.Title,
|
||||
GetStatus(result.Status),
|
||||
result.Message,
|
||||
result.Link.MatchUnsafe(l => l.Link, () => null));
|
||||
|
||||
private static string GetStatus(HealthCheckStatus status) =>
|
||||
status switch
|
||||
{
|
||||
HealthCheckStatus.Pass => "pass",
|
||||
HealthCheckStatus.Fail => "fail",
|
||||
HealthCheckStatus.Warning => "warn",
|
||||
HealthCheckStatus.Info => "info",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
|
||||
namespace ErsatzTV.Application.Health;
|
||||
|
||||
public record GetAllHealthCheckResultsForApi : IRequest<List<HealthCheckResponseModel>>;
|
||||
@@ -0,0 +1,32 @@
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
using ErsatzTV.Core.Health;
|
||||
using static ErsatzTV.Application.Health.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Health;
|
||||
|
||||
public class GetAllHealthCheckResultsForApiHandler
|
||||
: IRequestHandler<GetAllHealthCheckResultsForApi, List<HealthCheckResponseModel>>
|
||||
{
|
||||
private readonly IHealthCheckService _healthCheckService;
|
||||
|
||||
public GetAllHealthCheckResultsForApiHandler(IHealthCheckService healthCheckService) =>
|
||||
_healthCheckService = healthCheckService;
|
||||
|
||||
public async Task<List<HealthCheckResponseModel>> Handle(
|
||||
GetAllHealthCheckResultsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
|
||||
return results
|
||||
.Filter(r => r.Status != HealthCheckStatus.NotApplicable)
|
||||
.Map(ProjectToResponseModel)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+23
-9
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
+38
@@ -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; }
|
||||
|
||||
+99
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 =>
|
||||
{
|
||||
|
||||
+2802
-79
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user