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,24 @@
using System.Text.Json.Serialization;
using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record FFmpegSettingsResponseModel(
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,
[property: JsonConverter(typeof(JsonStringEnumConverter<OutputFormatKind>))]
OutputFormatKind HlsDirectOutputFormat,
string DefaultMpegTsScript);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record HdhrSettingsResponseModel(int TunerCount, Guid Uuid);
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
using Serilog.Events;
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record LoggingSettingsResponseModel(
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel DefaultMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel ScanningMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel SchedulingMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel SearchingMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel StreamingMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel HttpMinimumLogLevel);
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record PlayoutSettingsResponseModel(
int DaysToBuild,
bool SkipMissingItems,
int ScriptedScheduleTimeoutSeconds);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record ResolutionResponseModel(int Id, string Name, int Width, int Height, bool IsCustom);
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
/// <summary>Library scan cadence. <see cref="LibraryRefreshInterval" /> is expressed in hours.</summary>
public record ScannerSettingsResponseModel(int LibraryRefreshInterval);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record UiSettingsResponseModel(bool IsDarkMode, string Language);
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace ErsatzTV.Core.Api.Settings;
/// <summary>
/// Wire-contract mirror of <c>ErsatzTV.Application.Configuration.XmltvBlockBehavior</c>.
/// Duplicated here (rather than referenced directly) because <c>ErsatzTV.Core</c> may not depend on
/// <c>ErsatzTV.Application</c> (see <c>ErsatzTV.Architecture.Tests</c>); the controller mapper translates
/// between the two by value.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<XmltvBlockBehavior>))]
public enum XmltvBlockBehavior
{
SplitTimeEvenly = 0,
UseActualTimes = 1
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record XmltvSettingsResponseModel(
int DaysToBuild,
XmltvTimeZone TimeZone,
XmltvBlockBehavior BlockBehavior);
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace ErsatzTV.Core.Api.Settings;
/// <summary>
/// Wire-contract mirror of <c>ErsatzTV.Application.Configuration.XmltvTimeZone</c>.
/// Duplicated here (rather than referenced directly) because <c>ErsatzTV.Core</c> may not depend on
/// <c>ErsatzTV.Application</c> (see <c>ErsatzTV.Architecture.Tests</c>); the controller mapper translates
/// between the two by value.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<XmltvTimeZone>))]
public enum XmltvTimeZone
{
Local = 0,
Utc = 1
}
@@ -31,10 +31,12 @@ public class ApiControllerSecurityTests
typeof(LibrariesController),
typeof(MaintenanceController),
typeof(PlayoutController),
typeof(ResolutionController),
typeof(ScannerController),
typeof(ScheduleController),
typeof(ScriptedScheduleController),
typeof(SessionController),
typeof(SettingsController),
typeof(SmartCollectionController)
];
@@ -174,6 +174,15 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "404")]
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "401")]
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "422")]
[TestCase("/api/settings/ffmpeg", "put", "422")]
[TestCase("/api/settings/playout", "put", "422")]
[TestCase("/api/settings/xmltv", "put", "422")]
[TestCase("/api/settings/scanner", "put", "422")]
[TestCase("/api/settings/logging", "put", "422")]
[TestCase("/api/settings/ui", "put", "422")]
[TestCase("/api/settings/hdhr", "put", "422")]
[TestCase("/api/settings/resolutions", "post", "422")]
[TestCase("/api/settings/resolutions/{id}", "delete", "422")]
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
string path,
string method,
@@ -0,0 +1,103 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Settings;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ResolutionControllerTests
{
private ResolutionController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new ResolutionController(_mediator);
}
[Test]
public async Task GetAll_Should_Map_View_Models_To_Response_Models()
{
_mediator.Send(Arg.Any<GetAllResolutions>(), Arg.Any<CancellationToken>())
.Returns(
new List<ResolutionViewModel>
{
new(1, "1920x1080", 1920, 1080, false),
new(2, "1280x720", 1280, 720, true)
});
List<ResolutionResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(
[
new ResolutionResponseModel(1, "1920x1080", 1920, 1080, false),
new ResolutionResponseModel(2, "1280x720", 1280, 720, true)
]);
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
_mediator.Send(Arg.Any<CreateCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
_mediator.Send(Arg.Any<GetResolutionByName>(), Arg.Any<CancellationToken>())
.Returns(Option<ResolutionViewModel>.Some(new ResolutionViewModel(9, "640x480", 640, 480, true)));
IActionResult result = await _controller.Create(new CreateResolutionRequest(640, 480), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/settings/resolutions/9");
created.Value.ShouldBe(new ResolutionResponseModel(9, "640x480", 640, 480, true));
await _mediator.Received(1).Send(
Arg.Is<CreateCustomResolution>(c => c.Width == 640 && c.Height == 480),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Return_422_When_Resolution_Is_Not_Unique()
{
_mediator.Send(Arg.Any<CreateCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(BaseError.New("Resolution width and height must be unique")));
IActionResult result = await _controller.Create(new CreateResolutionRequest(1920, 1080), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
// The handler (DeleteCustomResolutionHandler) reports a missing/non-custom resolution as a plain
// BaseError, not a NotFoundError, so ApiResults.ToErrorResult maps it to 422 rather than 404 -
// this test documents that observed behavior rather than the ideal REST status code.
[Test]
public async Task Delete_Should_Return_422_For_Unknown_Or_NonCustom_Resolution()
{
_mediator.Send(Arg.Any<DeleteCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(BaseError.New("Resolution 42 does not exist.")));
IActionResult result = await _controller.Delete(42, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
}
@@ -0,0 +1,373 @@
using ErsatzTV.Application.Configuration;
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Application.HDHR;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Settings;
using ErsatzTV.FFmpeg.OutputFormat;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NUnit.Framework;
using Serilog.Events;
using Shouldly;
using static LanguageExt.Prelude;
using ApiXmltvBlockBehavior = ErsatzTV.Core.Api.Settings.XmltvBlockBehavior;
using ApiXmltvTimeZone = ErsatzTV.Core.Api.Settings.XmltvTimeZone;
using Unit = LanguageExt.Unit;
using VmXmltvBlockBehavior = ErsatzTV.Application.Configuration.XmltvBlockBehavior;
using VmXmltvTimeZone = ErsatzTV.Application.Configuration.XmltvTimeZone;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class SettingsControllerTests
{
private SettingsController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new SettingsController(_mediator);
}
[Test]
public async Task GetFfmpeg_Should_Map_Vm_To_Response_Model()
{
var vm = new FFmpegSettingsViewModel
{
FFmpegPath = "/usr/bin/ffmpeg",
FFprobePath = "/usr/bin/ffprobe",
DefaultFFmpegProfileId = 1,
PreferredAudioLanguageCode = "eng",
UseEmbeddedSubtitles = true,
ExtractEmbeddedSubtitles = false,
ProbeForInterlacedFrames = false,
SaveReports = false,
GlobalWatermarkId = 5,
GlobalFallbackFillerId = null,
HlsSegmenterIdleTimeout = 60,
WorkAheadSegmenterLimit = 1,
InitialSegmentCount = 1,
HlsDirectOutputFormat = OutputFormatKind.MpegTs,
DefaultMpegTsScript = "Default"
};
_mediator.Send(Arg.Any<GetFFmpegSettings>(), Arg.Any<CancellationToken>()).Returns(vm);
FFmpegSettingsResponseModel result = await _controller.GetFfmpeg(CancellationToken.None);
result.FFmpegPath.ShouldBe("/usr/bin/ffmpeg");
result.GlobalWatermarkId.ShouldBe(5);
result.GlobalFallbackFillerId.ShouldBeNull();
result.HlsDirectOutputFormat.ShouldBe(OutputFormatKind.MpegTs);
}
[Test]
public async Task UpdateFfmpeg_Should_Map_Request_To_Command_And_Return_Refreshed_Settings()
{
_mediator.Send(Arg.Any<UpdateFFmpegSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
var refreshed = new FFmpegSettingsViewModel
{
FFmpegPath = "/usr/bin/ffmpeg",
FFprobePath = "/usr/bin/ffprobe",
PreferredAudioLanguageCode = "eng",
HlsDirectOutputFormat = OutputFormatKind.Hls,
DefaultMpegTsScript = "Default"
};
_mediator.Send(Arg.Any<GetFFmpegSettings>(), Arg.Any<CancellationToken>()).Returns(refreshed);
IActionResult result = await _controller.UpdateFfmpeg(MakeFfmpegRequest(), CancellationToken.None);
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<FFmpegSettingsResponseModel>().HlsDirectOutputFormat.ShouldBe(OutputFormatKind.Hls);
await _mediator.Received(1).Send(Arg.Any<UpdateFFmpegSettings>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateFfmpeg_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateFFmpegSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("ffmpeg path does not exist")));
IActionResult result = await _controller.UpdateFfmpeg(MakeFfmpegRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetPlayout_Should_Map_Vm_To_Response_Model()
{
var vm = new PlayoutSettingsViewModel
{
DaysToBuild = 3,
SkipMissingItems = true,
ScriptedScheduleTimeoutSeconds = 45
};
_mediator.Send(Arg.Any<GetPlayoutSettings>(), Arg.Any<CancellationToken>()).Returns(vm);
PlayoutSettingsResponseModel result = await _controller.GetPlayout(CancellationToken.None);
result.ShouldBe(new PlayoutSettingsResponseModel(3, true, 45));
}
[Test]
public async Task UpdatePlayout_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdatePlayoutSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdatePlayout(
new UpdatePlayoutSettingsRequest(0, false, 30),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task UpdatePlayout_Should_Return_Refreshed_Settings_On_Success()
{
_mediator.Send(Arg.Any<UpdatePlayoutSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetPlayoutSettings>(), Arg.Any<CancellationToken>())
.Returns(new PlayoutSettingsViewModel { DaysToBuild = 5, SkipMissingItems = false, ScriptedScheduleTimeoutSeconds = 30 });
IActionResult result = await _controller.UpdatePlayout(
new UpdatePlayoutSettingsRequest(5, false, 30),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new PlayoutSettingsResponseModel(5, false, 30));
}
[Test]
public async Task GetXmltv_Should_Map_Vm_Enums_To_Api_Enums()
{
_mediator.Send(Arg.Any<GetXmltvSettings>(), Arg.Any<CancellationToken>())
.Returns(
new XmltvSettingsViewModel
{
DaysToBuild = 2,
TimeZone = VmXmltvTimeZone.Utc,
BlockBehavior = VmXmltvBlockBehavior.UseActualTimes
});
XmltvSettingsResponseModel result = await _controller.GetXmltv(CancellationToken.None);
result.ShouldBe(
new XmltvSettingsResponseModel(
2,
ApiXmltvTimeZone.Utc,
ApiXmltvBlockBehavior.UseActualTimes));
}
[Test]
public async Task UpdateXmltv_Should_Map_Api_Enums_To_Vm_Enums()
{
_mediator.Send(Arg.Any<UpdateXmltvSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetXmltvSettings>(), Arg.Any<CancellationToken>())
.Returns(new XmltvSettingsViewModel { DaysToBuild = 2, TimeZone = VmXmltvTimeZone.Local, BlockBehavior = VmXmltvBlockBehavior.SplitTimeEvenly });
IActionResult result = await _controller.UpdateXmltv(
new UpdateXmltvSettingsRequest(2, ApiXmltvTimeZone.Local, ApiXmltvBlockBehavior.SplitTimeEvenly),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdateXmltvSettings>(
c => c.XmltvSettings.TimeZone == VmXmltvTimeZone.Local &&
c.XmltvSettings.BlockBehavior == VmXmltvBlockBehavior.SplitTimeEvenly),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateXmltv_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateXmltvSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdateXmltv(
new UpdateXmltvSettingsRequest(
2,
ErsatzTV.Core.Api.Settings.XmltvTimeZone.Local,
ErsatzTV.Core.Api.Settings.XmltvBlockBehavior.SplitTimeEvenly),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetScanner_Should_Return_Library_Refresh_Interval()
{
_mediator.Send(Arg.Any<GetLibraryRefreshInterval>(), Arg.Any<CancellationToken>()).Returns(6);
ScannerSettingsResponseModel result = await _controller.GetScanner(CancellationToken.None);
result.ShouldBe(new ScannerSettingsResponseModel(6));
}
[Test]
public async Task UpdateScanner_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateLibraryRefreshInterval>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result =
await _controller.UpdateScanner(new UpdateScannerSettingsRequest(-1), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task UpdateScanner_Should_Return_Refreshed_Settings_On_Success()
{
_mediator.Send(Arg.Any<UpdateLibraryRefreshInterval>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetLibraryRefreshInterval>(), Arg.Any<CancellationToken>()).Returns(12);
IActionResult result =
await _controller.UpdateScanner(new UpdateScannerSettingsRequest(12), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new ScannerSettingsResponseModel(12));
}
[Test]
public async Task GetLogging_Should_Map_Vm_To_Response_Model()
{
var vm = new LoggingSettingsViewModel
{
DefaultMinimumLogLevel = LogEventLevel.Information,
ScanningMinimumLogLevel = LogEventLevel.Debug,
SchedulingMinimumLogLevel = LogEventLevel.Warning,
SearchingMinimumLogLevel = LogEventLevel.Error,
StreamingMinimumLogLevel = LogEventLevel.Verbose,
HttpMinimumLogLevel = LogEventLevel.Fatal
};
_mediator.Send(Arg.Any<GetLoggingSettings>(), Arg.Any<CancellationToken>()).Returns(vm);
LoggingSettingsResponseModel result = await _controller.GetLogging(CancellationToken.None);
result.DefaultMinimumLogLevel.ShouldBe(LogEventLevel.Information);
result.ScanningMinimumLogLevel.ShouldBe(LogEventLevel.Debug);
result.SchedulingMinimumLogLevel.ShouldBe(LogEventLevel.Warning);
result.SearchingMinimumLogLevel.ShouldBe(LogEventLevel.Error);
result.StreamingMinimumLogLevel.ShouldBe(LogEventLevel.Verbose);
result.HttpMinimumLogLevel.ShouldBe(LogEventLevel.Fatal);
}
[Test]
public async Task UpdateLogging_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateLoggingSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdateLogging(MakeLoggingRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetUi_Should_Map_Vm_To_Response_Model()
{
_mediator.Send(Arg.Any<GetUiSettings>(), Arg.Any<CancellationToken>())
.Returns(new UiSettingsViewModel { IsDarkMode = true, Language = "en" });
UiSettingsResponseModel result = await _controller.GetUi(CancellationToken.None);
result.ShouldBe(new UiSettingsResponseModel(true, "en"));
}
[Test]
public async Task UpdateUi_Should_Return_Refreshed_Settings_On_Success()
{
_mediator.Send(Arg.Any<UpdateUiSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetUiSettings>(), Arg.Any<CancellationToken>())
.Returns(new UiSettingsViewModel { IsDarkMode = false, Language = "fr" });
IActionResult result =
await _controller.UpdateUi(new UpdateUiSettingsRequest(false, "fr"), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new UiSettingsResponseModel(false, "fr"));
}
[Test]
public async Task UpdateUi_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateUiSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result =
await _controller.UpdateUi(new UpdateUiSettingsRequest(false, "fr"), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetHdhr_Should_Combine_Tuner_Count_And_Uuid()
{
var uuid = Guid.NewGuid();
_mediator.Send(Arg.Any<GetHDHRTunerCount>(), Arg.Any<CancellationToken>()).Returns(3);
_mediator.Send(Arg.Any<GetHDHRUUID>(), Arg.Any<CancellationToken>()).Returns(uuid);
HdhrSettingsResponseModel result = await _controller.GetHdhr(CancellationToken.None);
result.ShouldBe(new HdhrSettingsResponseModel(3, uuid));
}
[Test]
public async Task UpdateHdhr_Should_Return_Refreshed_Settings_On_Success()
{
var uuid = Guid.NewGuid();
_mediator.Send(Arg.Any<UpdateHDHRTunerCount>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetHDHRTunerCount>(), Arg.Any<CancellationToken>()).Returns(4);
_mediator.Send(Arg.Any<GetHDHRUUID>(), Arg.Any<CancellationToken>()).Returns(uuid);
IActionResult result = await _controller.UpdateHdhr(new UpdateHdhrSettingsRequest(4), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new HdhrSettingsResponseModel(4, uuid));
}
[Test]
public async Task UpdateHdhr_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateHDHRTunerCount>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("Tuner count must be greater than zero")));
IActionResult result = await _controller.UpdateHdhr(new UpdateHdhrSettingsRequest(0), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
private static UpdateFFmpegSettingsRequest MakeFfmpegRequest() =>
new(
"/usr/bin/ffmpeg",
"/usr/bin/ffprobe",
1,
"eng",
true,
false,
false,
false,
null,
null,
60,
1,
1,
OutputFormatKind.MpegTs,
"Default");
private static UpdateLoggingSettingsRequest MakeLoggingRequest() =>
new(
LogEventLevel.Information,
LogEventLevel.Information,
LogEventLevel.Information,
LogEventLevel.Information,
LogEventLevel.Information,
LogEventLevel.Information);
}
@@ -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