feat(api): REST foundation + Channels CRUD (#34)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m41s

First slice of the REST API (ersatztv#2). Idiomatic REST over existing MediatR handlers; design in docs/rest-api.md.

Foundation: NotFoundError : BaseError (Core) to split 404 from 422; ApiResults mapping helpers (Either/Option -> 201+Location / 200 / 204 / 404 / 422), additive (existing *ToActionResult untouched); ApiKeyAuthorizationFilter (optional X-Api-Key on mutating actions, gated by Api:WriteKey, no-op when unset) — independent of the IPTV JWT toggle so Jellyfin/Dispatcharr reads are unaffected, applied at controller-class level so every mutating action incl. ResetPlayout is covered fail-safe.

Channels: POST /api/channels (201+Location), PUT/{id} (200/404), DELETE/{id} (204/404), GET/{id} (200/404). Request DTOs -> existing commands; responses reuse ChannelViewModel. Ported page-only validations into handlers (ShowInEpg-when-disabled, external-logo-URL, Group NotEmpty) + FFmpegProfile/Watermark/Filler existence on update for create-parity. Fixed a latent 500: the .Filter(c>0) existence pattern threw TaskCanceledException on not-found; rewritten to AnyAsync.

Tests (NUnit, TZ=UTC): handler success/404/422, ApiResults mapping, API-key filter, controller status/Location/mapping, a controller-security regression net, and a create->read->delete EF integration test on a new in-memory SQLite harness. ErsatzTV.Tests 46/46, Architecture 5/5.

Refs #34

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #44.
This commit is contained in:
2026-06-27 15:51:06 +02:00
co-authored by Claude Opus 4.8
parent 12e6a07e3c
commit 8f7d240fc8
21 changed files with 1552 additions and 73 deletions
+65 -21
View File
@@ -1,9 +1,13 @@
using System.Threading.Channels;
using System.ComponentModel.DataAnnotations;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@@ -11,12 +15,72 @@ 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")]
[EndpointGroupName("general")]
public async Task<List<ChannelResponseModel>> GetAll() => await mediator.Send(new GetAllChannelsForApi());
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
[Tags("Channels")]
[EndpointSummary("Get a channel by id")]
[EndpointGroupName("general")]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ChannelViewModel> result = await mediator.Send(new GetChannelById(id), cancellationToken);
return result.ToGetResult();
}
[HttpPost("/api/channels")]
[Tags("Channels")]
[EndpointSummary("Create a channel")]
[EndpointGroupName("general")]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateChannelRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateChannelResult> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async created =>
{
Option<ChannelViewModel> channel =
await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken);
return channel.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/channels/{vm.Id}", vm),
None: () => new NotFoundResult());
});
}
[HttpPut("/api/channels/{id:int}")]
[Tags("Channels")]
[EndpointSummary("Update a channel")]
[EndpointGroupName("general")]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateChannelRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, ChannelViewModel> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToUpdatedResult();
}
[HttpDelete("/api/channels/{id:int}")]
[Tags("Channels")]
[EndpointSummary("Delete a channel")]
[EndpointGroupName("general")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteChannel(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
@@ -32,24 +96,4 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
return new NotFoundResult();
}
// for debugging by fast-forwarding a playout
// [HttpPost("/api/channels/{channelNumber}/playout/continue")]
// public async Task<IActionResult> ContinuePlayout(string channelNumber, [FromQuery] int days = 1)
// {
// Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
// foreach (int playoutId in maybePlayoutId)
// {
// DateTimeOffset start = DateTimeOffset.Now;
// for (int i = 0; i < 24 * days; i++)
// {
// await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue, start));
// start += TimeSpan.FromHours(1);
// }
//
// return new OkResult();
// }
//
// return new NotFoundResult();
// }
}
@@ -0,0 +1,69 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>
/// JSON request body for creating a channel. Mirrors <see cref="CreateChannel" />; decoupling the
/// API contract from the MediatR command keeps the wire format stable as the command evolves.
/// </summary>
public record CreateChannelRequest(
string Name,
string Number,
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg)
{
public CreateChannel ToCommand() =>
new(
Name,
Number,
Group,
Categories,
FFmpegProfileId,
SlugSeconds,
Logo,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
MirrorSourceChannelId,
PlayoutOffset,
StreamingMode,
WatermarkId,
FallbackFillerId,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
IsEnabled,
ShowInEpg);
}
@@ -0,0 +1,70 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>
/// JSON request body for updating a channel. The channel id comes from the route, not the body;
/// all other fields mirror <see cref="UpdateChannel" />.
/// </summary>
public record UpdateChannelRequest(
string Name,
string Number,
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg)
{
public UpdateChannel ToCommand(int channelId) =>
new(
channelId,
Name,
Number,
Group,
Categories,
FFmpegProfileId,
SlugSeconds,
Logo,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
MirrorSourceChannelId,
PlayoutOffset,
StreamingMode,
WatermarkId,
FallbackFillerId,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
IsEnabled,
ShowInEpg);
}
+49
View File
@@ -0,0 +1,49 @@
using System.Diagnostics.CodeAnalysis;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Extensions;
/// <summary>
/// REST status-code mapping helpers for the JSON write/read API (slice #2a).
/// These are additive and must not change the behavior of the existing
/// <see cref="EitherToActionResult" /> / <see cref="OptionToActionResult" /> helpers,
/// which IptvController and other read endpoints depend on.
/// </summary>
[SuppressMessage("ReSharper", "VSTHRD003")]
public static class ApiResults
{
/// <summary>Maps a failure to 404 when it is a <see cref="NotFoundError" />, otherwise 422.</summary>
public static IActionResult ToErrorResult(this BaseError error) =>
error is NotFoundError
? new NotFoundObjectResult(error.Value)
: new UnprocessableEntityObjectResult(error.Value);
/// <summary>Right: 201 Created with a Location header and body; Left: 404 (NotFound) or 422.</summary>
public static IActionResult ToCreatedResult<TR>(
this Either<BaseError, TR> either,
Func<TR, string> location,
Func<TR, object> body) =>
either.Match(
Left: error => error.ToErrorResult(),
Right: value => new CreatedResult(location(value), body(value)));
/// <summary>Right: 200 with body; Left: 404 (NotFound) or 422.</summary>
public static IActionResult ToUpdatedResult<TR>(this Either<BaseError, TR> either) =>
either.Match(
Left: error => error.ToErrorResult(),
Right: value => (IActionResult)new OkObjectResult(value));
/// <summary>Right(Unit): 204 No Content; Left: 404 (NotFound) or 422.</summary>
public static IActionResult ToDeletedResult(this Either<BaseError, Unit> either) =>
either.Match(
Left: error => error.ToErrorResult(),
Right: _ => (IActionResult)new NoContentResult());
/// <summary>Some: 200 with body; None: 404.</summary>
public static IActionResult ToGetResult<T>(this Option<T> option) =>
option.Match(
Some: value => (IActionResult)new OkObjectResult(value),
None: () => new NotFoundResult());
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Primitives;
namespace ErsatzTV.Filters;
/// <summary>
/// Optional API-key authorization for mutating JSON API endpoints (slice #2a).
/// 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.
/// </summary>
public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthorizationFilter
{
public const string HeaderName = "X-Api-Key";
public const string ConfigurationKey = "Api:WriteKey";
public void OnAuthorization(AuthorizationFilterContext context)
{
string configuredKey = configuration[ConfigurationKey];
// empty key => API-key auth disabled, endpoint is open
if (string.IsNullOrEmpty(configuredKey))
{
return;
}
string method = context.HttpContext.Request.Method;
bool isMutating = HttpMethods.IsPost(method)
|| HttpMethods.IsPut(method)
|| HttpMethods.IsPatch(method)
|| HttpMethods.IsDelete(method);
if (!isMutating)
{
return;
}
if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out StringValues provided)
|| !string.Equals(provided.ToString(), configuredKey, StringComparison.Ordinal))
{
context.Result = new UnauthorizedResult();
}
}
}
+3
View File
@@ -309,6 +309,9 @@ public class Startup
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
// optional API-key authorization for mutating JSON API endpoints (independent of JWT/OIDC)
services.AddScoped<ApiKeyAuthorizationFilter>();
services.AddFluentValidationAutoValidation();
services.AddValidatorsFromAssemblyContaining<Startup>();