feat(api): settings read/write endpoints (#93)

GET/PUT /api/settings/{ffmpeg,playout,xmltv,scanner,logging,ui,hdhr}
wrapping the existing MediatR settings handlers, plus custom resolution
list/create/delete under /api/settings/resolutions. String-enum OpenAPI
schemas extended to OutputFormatKind + LogEventLevel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 09:22:50 +02:00
co-authored by Claude Fable 5
parent 82a93ea3be
commit 57905bc0f4
26 changed files with 2464 additions and 0 deletions
@@ -0,0 +1,8 @@
using ErsatzTV.Application.Resolutions;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateResolutionRequest(int Width, int Height)
{
public CreateCustomResolution ToCommand() => new(Width, Height);
}
@@ -0,0 +1,43 @@
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateFFmpegSettingsRequest(
string FFmpegPath,
string FFprobePath,
int DefaultFFmpegProfileId,
string PreferredAudioLanguageCode,
bool UseEmbeddedSubtitles,
bool ExtractEmbeddedSubtitles,
bool ProbeForInterlacedFrames,
bool SaveReports,
int? GlobalWatermarkId,
int? GlobalFallbackFillerId,
int HlsSegmenterIdleTimeout,
int WorkAheadSegmenterLimit,
int InitialSegmentCount,
OutputFormatKind HlsDirectOutputFormat,
string DefaultMpegTsScript)
{
public UpdateFFmpegSettings ToCommand() =>
new(
new FFmpegSettingsViewModel
{
FFmpegPath = FFmpegPath,
FFprobePath = FFprobePath,
DefaultFFmpegProfileId = DefaultFFmpegProfileId,
PreferredAudioLanguageCode = PreferredAudioLanguageCode,
UseEmbeddedSubtitles = UseEmbeddedSubtitles,
ExtractEmbeddedSubtitles = ExtractEmbeddedSubtitles,
ProbeForInterlacedFrames = ProbeForInterlacedFrames,
SaveReports = SaveReports,
GlobalWatermarkId = GlobalWatermarkId,
GlobalFallbackFillerId = GlobalFallbackFillerId,
HlsSegmenterIdleTimeout = HlsSegmenterIdleTimeout,
WorkAheadSegmenterLimit = WorkAheadSegmenterLimit,
InitialSegmentCount = InitialSegmentCount,
HlsDirectOutputFormat = HlsDirectOutputFormat,
DefaultMpegTsScript = DefaultMpegTsScript
});
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.HDHR;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateHdhrSettingsRequest(int TunerCount)
{
public UpdateHDHRTunerCount ToCommand() => new(TunerCount);
}
@@ -0,0 +1,25 @@
using ErsatzTV.Application.Configuration;
using Serilog.Events;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateLoggingSettingsRequest(
LogEventLevel DefaultMinimumLogLevel,
LogEventLevel ScanningMinimumLogLevel,
LogEventLevel SchedulingMinimumLogLevel,
LogEventLevel SearchingMinimumLogLevel,
LogEventLevel StreamingMinimumLogLevel,
LogEventLevel HttpMinimumLogLevel)
{
public UpdateLoggingSettings ToCommand() =>
new(
new LoggingSettingsViewModel
{
DefaultMinimumLogLevel = DefaultMinimumLogLevel,
ScanningMinimumLogLevel = ScanningMinimumLogLevel,
SchedulingMinimumLogLevel = SchedulingMinimumLogLevel,
SearchingMinimumLogLevel = SearchingMinimumLogLevel,
StreamingMinimumLogLevel = StreamingMinimumLogLevel,
HttpMinimumLogLevel = HttpMinimumLogLevel
});
}
@@ -0,0 +1,15 @@
using ErsatzTV.Application.Configuration;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdatePlayoutSettingsRequest(int DaysToBuild, bool SkipMissingItems, int ScriptedScheduleTimeoutSeconds)
{
public UpdatePlayoutSettings ToCommand() =>
new(
new PlayoutSettingsViewModel
{
DaysToBuild = DaysToBuild,
SkipMissingItems = SkipMissingItems,
ScriptedScheduleTimeoutSeconds = ScriptedScheduleTimeoutSeconds
});
}
@@ -0,0 +1,9 @@
using ErsatzTV.Application.Configuration;
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>Library scan cadence. <see cref="LibraryRefreshInterval" /> is expressed in hours.</summary>
public record UpdateScannerSettingsRequest(int LibraryRefreshInterval)
{
public UpdateLibraryRefreshInterval ToCommand() => new(LibraryRefreshInterval);
}
@@ -0,0 +1,14 @@
using ErsatzTV.Application.Configuration;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateUiSettingsRequest(bool IsDarkMode, string Language)
{
public UpdateUiSettings ToCommand() =>
new(
new UiSettingsViewModel
{
IsDarkMode = IsDarkMode,
Language = Language
});
}
@@ -0,0 +1,17 @@
using ErsatzTV.Application.Configuration;
using ApiXmltvBlockBehavior = ErsatzTV.Core.Api.Settings.XmltvBlockBehavior;
using ApiXmltvTimeZone = ErsatzTV.Core.Api.Settings.XmltvTimeZone;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateXmltvSettingsRequest(int DaysToBuild, ApiXmltvTimeZone TimeZone, ApiXmltvBlockBehavior BlockBehavior)
{
public UpdateXmltvSettings ToCommand() =>
new(
new XmltvSettingsViewModel
{
DaysToBuild = DaysToBuild,
TimeZone = (XmltvTimeZone)(int)TimeZone,
BlockBehavior = (XmltvBlockBehavior)(int)BlockBehavior
});
}
@@ -1,5 +1,11 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Settings;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
@@ -14,5 +20,57 @@ public class ResolutionController(IMediator mediator) : ControllerBase
Option<ResolutionViewModel> result = await mediator.Send(new GetResolutionByName(name), cancellationToken);
return result.Match<ActionResult<ResolutionViewModel>>(i => Ok(i), () => NotFound());
}
[HttpGet("/api/settings/resolutions", Name = "GetResolutions")]
[Tags("Settings")]
[EndpointSummary("Get all resolutions, including custom resolutions")]
[ProducesResponseType(typeof(List<ResolutionResponseModel>), StatusCodes.Status200OK)]
public async Task<List<ResolutionResponseModel>> GetAll(CancellationToken cancellationToken)
{
List<ResolutionViewModel> resolutions = await mediator.Send(new GetAllResolutions(), cancellationToken);
return resolutions.Map(ProjectToResponseModel).ToList();
}
[HttpPost("/api/settings/resolutions", Name = "CreateResolution")]
[Tags("Settings")]
[EndpointSummary("Create a custom resolution")]
[ProducesResponseType(typeof(ResolutionResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody]
CreateResolutionRequest request,
CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Some: error => Task.FromResult(error.ToErrorResult()),
None: async () =>
{
string name = $"{request.Width}x{request.Height}";
Option<ResolutionViewModel> resolution =
await mediator.Send(new GetResolutionByName(name), cancellationToken);
return resolution.Match(
Some: vm => (IActionResult)new CreatedResult(
$"/api/settings/resolutions/{vm.Id}",
ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpDelete("/api/settings/resolutions/{id:int}", Name = "DeleteResolution")]
[Tags("Settings")]
[EndpointSummary("Delete a custom resolution")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeleteCustomResolution(id), cancellationToken);
return result.Match<IActionResult>(
Some: error => error.ToErrorResult(),
None: () => new NoContentResult());
}
private static ResolutionResponseModel ProjectToResponseModel(ResolutionViewModel vm) =>
new(vm.Id, vm.Name, vm.Width, vm.Height, vm.IsCustom);
}
@@ -0,0 +1,280 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Application.HDHR;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Settings;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using ApiXmltvBlockBehavior = ErsatzTV.Core.Api.Settings.XmltvBlockBehavior;
using ApiXmltvTimeZone = ErsatzTV.Core.Api.Settings.XmltvTimeZone;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class SettingsController(IMediator mediator) : ControllerBase
{
// FFmpeg settings
[HttpGet("/api/settings/ffmpeg", Name = "GetFfmpegSettings")]
[Tags("Settings")]
[EndpointSummary("Get global FFmpeg settings")]
[ProducesResponseType(typeof(FFmpegSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<FFmpegSettingsResponseModel> GetFfmpeg(CancellationToken cancellationToken)
{
FFmpegSettingsViewModel settings = await mediator.Send(new GetFFmpegSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/ffmpeg", Name = "UpdateFfmpegSettings")]
[Tags("Settings")]
[EndpointSummary("Update global FFmpeg settings")]
[ProducesResponseType(typeof(FFmpegSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateFfmpeg(
[Required] [FromBody]
UpdateFFmpegSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
FFmpegSettingsViewModel settings = await mediator.Send(new GetFFmpegSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// Playout settings
[HttpGet("/api/settings/playout", Name = "GetPlayoutSettings")]
[Tags("Settings")]
[EndpointSummary("Get global playout settings")]
[ProducesResponseType(typeof(PlayoutSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<PlayoutSettingsResponseModel> GetPlayout(CancellationToken cancellationToken)
{
PlayoutSettingsViewModel settings = await mediator.Send(new GetPlayoutSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/playout", Name = "UpdatePlayoutSettings")]
[Tags("Settings")]
[EndpointSummary("Update global playout settings")]
[ProducesResponseType(typeof(PlayoutSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdatePlayout(
[Required] [FromBody]
UpdatePlayoutSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
PlayoutSettingsViewModel settings = await mediator.Send(new GetPlayoutSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// XMLTV settings
[HttpGet("/api/settings/xmltv", Name = "GetXmltvSettings")]
[Tags("Settings")]
[EndpointSummary("Get global XMLTV settings")]
[ProducesResponseType(typeof(XmltvSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<XmltvSettingsResponseModel> GetXmltv(CancellationToken cancellationToken)
{
XmltvSettingsViewModel settings = await mediator.Send(new GetXmltvSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/xmltv", Name = "UpdateXmltvSettings")]
[Tags("Settings")]
[EndpointSummary("Update global XMLTV settings")]
[ProducesResponseType(typeof(XmltvSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateXmltv(
[Required] [FromBody]
UpdateXmltvSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
XmltvSettingsViewModel settings = await mediator.Send(new GetXmltvSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// Scanner settings
[HttpGet("/api/settings/scanner", Name = "GetScannerSettings")]
[Tags("Settings")]
[EndpointSummary("Get library scan cadence")]
[ProducesResponseType(typeof(ScannerSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<ScannerSettingsResponseModel> GetScanner(CancellationToken cancellationToken)
{
int libraryRefreshInterval = await mediator.Send(new GetLibraryRefreshInterval(), cancellationToken);
return new ScannerSettingsResponseModel(libraryRefreshInterval);
}
[HttpPut("/api/settings/scanner", Name = "UpdateScannerSettings")]
[Tags("Settings")]
[EndpointSummary("Update library scan cadence")]
[ProducesResponseType(typeof(ScannerSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateScanner(
[Required] [FromBody]
UpdateScannerSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
int libraryRefreshInterval = await mediator.Send(new GetLibraryRefreshInterval(), cancellationToken);
return (IActionResult)new OkObjectResult(new ScannerSettingsResponseModel(libraryRefreshInterval));
});
}
// Logging settings
[HttpGet("/api/settings/logging", Name = "GetLoggingSettings")]
[Tags("Settings")]
[EndpointSummary("Get per-area minimum log levels")]
[ProducesResponseType(typeof(LoggingSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<LoggingSettingsResponseModel> GetLogging(CancellationToken cancellationToken)
{
LoggingSettingsViewModel settings = await mediator.Send(new GetLoggingSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/logging", Name = "UpdateLoggingSettings")]
[Tags("Settings")]
[EndpointSummary("Update per-area minimum log levels")]
[ProducesResponseType(typeof(LoggingSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateLogging(
[Required] [FromBody]
UpdateLoggingSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
LoggingSettingsViewModel settings = await mediator.Send(new GetLoggingSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// UI settings
[HttpGet("/api/settings/ui", Name = "GetUiSettings")]
[Tags("Settings")]
[EndpointSummary("Get UI preferences")]
[ProducesResponseType(typeof(UiSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<UiSettingsResponseModel> GetUi(CancellationToken cancellationToken)
{
UiSettingsViewModel settings = await mediator.Send(new GetUiSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/ui", Name = "UpdateUiSettings")]
[Tags("Settings")]
[EndpointSummary("Update UI preferences")]
[ProducesResponseType(typeof(UiSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateUi(
[Required] [FromBody]
UpdateUiSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
UiSettingsViewModel settings = await mediator.Send(new GetUiSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// HDHR settings
[HttpGet("/api/settings/hdhr", Name = "GetHdhrSettings")]
[Tags("Settings")]
[EndpointSummary("Get HDHomeRun emulation settings")]
[ProducesResponseType(typeof(HdhrSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<HdhrSettingsResponseModel> GetHdhr(CancellationToken cancellationToken) =>
await LoadHdhrSettings(cancellationToken);
[HttpPut("/api/settings/hdhr", Name = "UpdateHdhrSettings")]
[Tags("Settings")]
[EndpointSummary("Update HDHomeRun emulation settings")]
[ProducesResponseType(typeof(HdhrSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateHdhr(
[Required] [FromBody]
UpdateHdhrSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ => (IActionResult)new OkObjectResult(await LoadHdhrSettings(cancellationToken)));
}
private async Task<HdhrSettingsResponseModel> LoadHdhrSettings(CancellationToken cancellationToken)
{
int tunerCount = await mediator.Send(new GetHDHRTunerCount(), cancellationToken);
Guid uuid = await mediator.Send(new GetHDHRUUID(), cancellationToken);
return new HdhrSettingsResponseModel(tunerCount, uuid);
}
private static FFmpegSettingsResponseModel ProjectToResponseModel(FFmpegSettingsViewModel vm) =>
new(
vm.FFmpegPath,
vm.FFprobePath,
vm.DefaultFFmpegProfileId,
vm.PreferredAudioLanguageCode,
vm.UseEmbeddedSubtitles,
vm.ExtractEmbeddedSubtitles,
vm.ProbeForInterlacedFrames,
vm.SaveReports,
vm.GlobalWatermarkId,
vm.GlobalFallbackFillerId,
vm.HlsSegmenterIdleTimeout,
vm.WorkAheadSegmenterLimit,
vm.InitialSegmentCount,
vm.HlsDirectOutputFormat,
vm.DefaultMpegTsScript);
private static PlayoutSettingsResponseModel ProjectToResponseModel(PlayoutSettingsViewModel vm) =>
new(vm.DaysToBuild, vm.SkipMissingItems, vm.ScriptedScheduleTimeoutSeconds);
private static XmltvSettingsResponseModel ProjectToResponseModel(XmltvSettingsViewModel vm) =>
new(vm.DaysToBuild, (ApiXmltvTimeZone)(int)vm.TimeZone, (ApiXmltvBlockBehavior)(int)vm.BlockBehavior);
private static LoggingSettingsResponseModel ProjectToResponseModel(LoggingSettingsViewModel vm) =>
new(
vm.DefaultMinimumLogLevel,
vm.ScanningMinimumLogLevel,
vm.SchedulingMinimumLogLevel,
vm.SearchingMinimumLogLevel,
vm.StreamingMinimumLogLevel,
vm.HttpMinimumLogLevel);
private static UiSettingsResponseModel ProjectToResponseModel(UiSettingsViewModel vm) =>
new(vm.IsDarkMode, vm.Language);
}
+6
View File
@@ -127,8 +127,14 @@ public class Startup
return;
}
// Core's own enums get scanned wholesale. A couple of API response DTOs (settings endpoints)
// also expose enums from lower layers Core is allowed to depend on (ErsatzTV.FFmpeg) or from
// Serilog; list those individually instead of Assembly.GetTypes()-scanning their assemblies,
// since eagerly loading every type in ErsatzTV.FFmpeg (e.g. NvEncSharp-backed types) can throw
// a ReflectionTypeLoadException in environments missing optional native hardware-encoder deps.
Dictionary<string, Type> enumTypes = typeof(Core.Domain.PlayoutMode).Assembly.GetTypes()
.Where(type => type.IsEnum)
.Concat([typeof(FFmpeg.OutputFormat.OutputFormatKind), typeof(Serilog.Events.LogEventLevel)])
.GroupBy(type => type.Name)
.ToDictionary(group => group.Key, group => group.First());
File diff suppressed because it is too large Load Diff