From 57905bc0f4c260f7171f9dcdf2318822bb41e42a Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 08:22:29 +0200 Subject: [PATCH 1/6] 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 --- .../Settings/FFmpegSettingsResponseModel.cs | 24 + .../Api/Settings/HdhrSettingsResponseModel.cs | 5 + .../Settings/LoggingSettingsResponseModel.cs | 20 + .../Settings/PlayoutSettingsResponseModel.cs | 8 + .../Api/Settings/ResolutionResponseModel.cs | 5 + .../Settings/ScannerSettingsResponseModel.cs | 6 + .../Api/Settings/UiSettingsResponseModel.cs | 5 + .../Api/Settings/XmltvBlockBehavior.cs | 16 + .../Settings/XmltvSettingsResponseModel.cs | 8 + ErsatzTV.Core/Api/Settings/XmltvTimeZone.cs | 16 + .../Controllers/ApiControllerSecurityTests.cs | 2 + .../OpenApiErrorResponseContractTests.cs | 9 + .../Controllers/ResolutionControllerTests.cs | 103 ++ .../Controllers/SettingsControllerTests.cs | 373 +++++ .../Api/Requests/CreateResolutionRequest.cs | 8 + .../Requests/UpdateFFmpegSettingsRequest.cs | 43 + .../Api/Requests/UpdateHdhrSettingsRequest.cs | 8 + .../Requests/UpdateLoggingSettingsRequest.cs | 25 + .../Requests/UpdatePlayoutSettingsRequest.cs | 15 + .../Requests/UpdateScannerSettingsRequest.cs | 9 + .../Api/Requests/UpdateUiSettingsRequest.cs | 14 + .../Requests/UpdateXmltvSettingsRequest.cs | 17 + .../Controllers/Api/ResolutionController.cs | 58 + .../Controllers/Api/SettingsController.cs | 280 ++++ ErsatzTV/Startup.cs | 6 + ErsatzTV/wwwroot/openapi/v1.json | 1381 +++++++++++++++++ 26 files changed, 2464 insertions(+) create mode 100644 ErsatzTV.Core/Api/Settings/FFmpegSettingsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Settings/HdhrSettingsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Settings/LoggingSettingsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Settings/PlayoutSettingsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Settings/ResolutionResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Settings/ScannerSettingsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Settings/UiSettingsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Settings/XmltvBlockBehavior.cs create mode 100644 ErsatzTV.Core/Api/Settings/XmltvSettingsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Settings/XmltvTimeZone.cs create mode 100644 ErsatzTV.Tests/Controllers/ResolutionControllerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/SettingsControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreateResolutionRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateFFmpegSettingsRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateHdhrSettingsRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateLoggingSettingsRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdatePlayoutSettingsRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateScannerSettingsRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateUiSettingsRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateXmltvSettingsRequest.cs create mode 100644 ErsatzTV/Controllers/Api/SettingsController.cs diff --git a/ErsatzTV.Core/Api/Settings/FFmpegSettingsResponseModel.cs b/ErsatzTV.Core/Api/Settings/FFmpegSettingsResponseModel.cs new file mode 100644 index 000000000..338732cd8 --- /dev/null +++ b/ErsatzTV.Core/Api/Settings/FFmpegSettingsResponseModel.cs @@ -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 HlsDirectOutputFormat, + string DefaultMpegTsScript); diff --git a/ErsatzTV.Core/Api/Settings/HdhrSettingsResponseModel.cs b/ErsatzTV.Core/Api/Settings/HdhrSettingsResponseModel.cs new file mode 100644 index 000000000..def6ce4b2 --- /dev/null +++ b/ErsatzTV.Core/Api/Settings/HdhrSettingsResponseModel.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Core.Api.Settings; + +#nullable enable + +public record HdhrSettingsResponseModel(int TunerCount, Guid Uuid); diff --git a/ErsatzTV.Core/Api/Settings/LoggingSettingsResponseModel.cs b/ErsatzTV.Core/Api/Settings/LoggingSettingsResponseModel.cs new file mode 100644 index 000000000..39bcbfecb --- /dev/null +++ b/ErsatzTV.Core/Api/Settings/LoggingSettingsResponseModel.cs @@ -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 DefaultMinimumLogLevel, + [property: JsonConverter(typeof(JsonStringEnumConverter))] + LogEventLevel ScanningMinimumLogLevel, + [property: JsonConverter(typeof(JsonStringEnumConverter))] + LogEventLevel SchedulingMinimumLogLevel, + [property: JsonConverter(typeof(JsonStringEnumConverter))] + LogEventLevel SearchingMinimumLogLevel, + [property: JsonConverter(typeof(JsonStringEnumConverter))] + LogEventLevel StreamingMinimumLogLevel, + [property: JsonConverter(typeof(JsonStringEnumConverter))] + LogEventLevel HttpMinimumLogLevel); diff --git a/ErsatzTV.Core/Api/Settings/PlayoutSettingsResponseModel.cs b/ErsatzTV.Core/Api/Settings/PlayoutSettingsResponseModel.cs new file mode 100644 index 000000000..4a805a9e1 --- /dev/null +++ b/ErsatzTV.Core/Api/Settings/PlayoutSettingsResponseModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api.Settings; + +#nullable enable + +public record PlayoutSettingsResponseModel( + int DaysToBuild, + bool SkipMissingItems, + int ScriptedScheduleTimeoutSeconds); diff --git a/ErsatzTV.Core/Api/Settings/ResolutionResponseModel.cs b/ErsatzTV.Core/Api/Settings/ResolutionResponseModel.cs new file mode 100644 index 000000000..fa2c8c7ac --- /dev/null +++ b/ErsatzTV.Core/Api/Settings/ResolutionResponseModel.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Core.Api.Settings; + +#nullable enable + +public record ResolutionResponseModel(int Id, string Name, int Width, int Height, bool IsCustom); diff --git a/ErsatzTV.Core/Api/Settings/ScannerSettingsResponseModel.cs b/ErsatzTV.Core/Api/Settings/ScannerSettingsResponseModel.cs new file mode 100644 index 000000000..70d03cbea --- /dev/null +++ b/ErsatzTV.Core/Api/Settings/ScannerSettingsResponseModel.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Api.Settings; + +#nullable enable + +/// Library scan cadence. is expressed in hours. +public record ScannerSettingsResponseModel(int LibraryRefreshInterval); diff --git a/ErsatzTV.Core/Api/Settings/UiSettingsResponseModel.cs b/ErsatzTV.Core/Api/Settings/UiSettingsResponseModel.cs new file mode 100644 index 000000000..c586f21e6 --- /dev/null +++ b/ErsatzTV.Core/Api/Settings/UiSettingsResponseModel.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Core.Api.Settings; + +#nullable enable + +public record UiSettingsResponseModel(bool IsDarkMode, string Language); diff --git a/ErsatzTV.Core/Api/Settings/XmltvBlockBehavior.cs b/ErsatzTV.Core/Api/Settings/XmltvBlockBehavior.cs new file mode 100644 index 000000000..ec4748333 --- /dev/null +++ b/ErsatzTV.Core/Api/Settings/XmltvBlockBehavior.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace ErsatzTV.Core.Api.Settings; + +/// +/// Wire-contract mirror of ErsatzTV.Application.Configuration.XmltvBlockBehavior. +/// Duplicated here (rather than referenced directly) because ErsatzTV.Core may not depend on +/// ErsatzTV.Application (see ErsatzTV.Architecture.Tests); the controller mapper translates +/// between the two by value. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum XmltvBlockBehavior +{ + SplitTimeEvenly = 0, + UseActualTimes = 1 +} diff --git a/ErsatzTV.Core/Api/Settings/XmltvSettingsResponseModel.cs b/ErsatzTV.Core/Api/Settings/XmltvSettingsResponseModel.cs new file mode 100644 index 000000000..e0a9fa356 --- /dev/null +++ b/ErsatzTV.Core/Api/Settings/XmltvSettingsResponseModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api.Settings; + +#nullable enable + +public record XmltvSettingsResponseModel( + int DaysToBuild, + XmltvTimeZone TimeZone, + XmltvBlockBehavior BlockBehavior); diff --git a/ErsatzTV.Core/Api/Settings/XmltvTimeZone.cs b/ErsatzTV.Core/Api/Settings/XmltvTimeZone.cs new file mode 100644 index 000000000..b26f88508 --- /dev/null +++ b/ErsatzTV.Core/Api/Settings/XmltvTimeZone.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace ErsatzTV.Core.Api.Settings; + +/// +/// Wire-contract mirror of ErsatzTV.Application.Configuration.XmltvTimeZone. +/// Duplicated here (rather than referenced directly) because ErsatzTV.Core may not depend on +/// ErsatzTV.Application (see ErsatzTV.Architecture.Tests); the controller mapper translates +/// between the two by value. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum XmltvTimeZone +{ + Local = 0, + Utc = 1 +} diff --git a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs index 4f00c1279..a0142cc13 100644 --- a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs @@ -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) ]; diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 1cbec4683..29055f2c5 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -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, diff --git a/ErsatzTV.Tests/Controllers/ResolutionControllerTests.cs b/ErsatzTV.Tests/Controllers/ResolutionControllerTests.cs new file mode 100644 index 000000000..ddb1bde5a --- /dev/null +++ b/ErsatzTV.Tests/Controllers/ResolutionControllerTests.cs @@ -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(); + _controller = new ResolutionController(_mediator); + } + + [Test] + public async Task GetAll_Should_Map_View_Models_To_Response_Models() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + new List + { + new(1, "1920x1080", 1920, 1080, false), + new(2, "1280x720", 1280, 720, true) + }); + + List 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(), Arg.Any()) + .Returns(Option.None); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new ResolutionViewModel(9, "640x480", 640, 480, true))); + + IActionResult result = await _controller.Create(new CreateResolutionRequest(640, 480), CancellationToken.None); + + var created = result.ShouldBeOfType(); + 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(c => c.Width == 640 && c.Height == 480), + Arg.Any()); + } + + [Test] + public async Task Create_Should_Return_422_When_Resolution_Is_Not_Unique() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(BaseError.New("Resolution width and height must be unique"))); + + IActionResult result = await _controller.Create(new CreateResolutionRequest(1920, 1080), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Delete(9, CancellationToken.None); + + result.ShouldBeOfType(); + } + + // 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(), Arg.Any()) + .Returns(Option.Some(BaseError.New("Resolution 42 does not exist."))); + + IActionResult result = await _controller.Delete(42, CancellationToken.None); + + result.ShouldBeOfType(); + } +} diff --git a/ErsatzTV.Tests/Controllers/SettingsControllerTests.cs b/ErsatzTV.Tests/Controllers/SettingsControllerTests.cs new file mode 100644 index 000000000..687c7af5a --- /dev/null +++ b/ErsatzTV.Tests/Controllers/SettingsControllerTests.cs @@ -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(); + _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(), Arg.Any()).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(), Arg.Any()) + .Returns(Right(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(), Arg.Any()).Returns(refreshed); + + IActionResult result = await _controller.UpdateFfmpeg(MakeFfmpegRequest(), CancellationToken.None); + + var ok = result.ShouldBeOfType(); + ok.Value.ShouldBeOfType().HlsDirectOutputFormat.ShouldBe(OutputFormatKind.Hls); + await _mediator.Received(1).Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task UpdateFfmpeg_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("ffmpeg path does not exist"))); + + IActionResult result = await _controller.UpdateFfmpeg(MakeFfmpegRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [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(), Arg.Any()).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(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.UpdatePlayout( + new UpdatePlayoutSettingsRequest(0, false, 30), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task UpdatePlayout_Should_Return_Refreshed_Settings_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PlayoutSettingsViewModel { DaysToBuild = 5, SkipMissingItems = false, ScriptedScheduleTimeoutSeconds = 30 }); + + IActionResult result = await _controller.UpdatePlayout( + new UpdatePlayoutSettingsRequest(5, false, 30), + CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(new PlayoutSettingsResponseModel(5, false, 30)); + } + + [Test] + public async Task GetXmltv_Should_Map_Vm_Enums_To_Api_Enums() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .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(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .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(); + await _mediator.Received(1).Send( + Arg.Is( + c => c.XmltvSettings.TimeZone == VmXmltvTimeZone.Local && + c.XmltvSettings.BlockBehavior == VmXmltvBlockBehavior.SplitTimeEvenly), + Arg.Any()); + } + + [Test] + public async Task UpdateXmltv_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(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(); + } + + [Test] + public async Task GetScanner_Should_Return_Library_Refresh_Interval() + { + _mediator.Send(Arg.Any(), Arg.Any()).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(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = + await _controller.UpdateScanner(new UpdateScannerSettingsRequest(-1), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task UpdateScanner_Should_Return_Refreshed_Settings_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(12); + + IActionResult result = + await _controller.UpdateScanner(new UpdateScannerSettingsRequest(12), CancellationToken.None); + + result.ShouldBeOfType().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(), Arg.Any()).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(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.UpdateLogging(MakeLoggingRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetUi_Should_Map_Vm_To_Response_Model() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .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(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new UiSettingsViewModel { IsDarkMode = false, Language = "fr" }); + + IActionResult result = + await _controller.UpdateUi(new UpdateUiSettingsRequest(false, "fr"), CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(new UiSettingsResponseModel(false, "fr")); + } + + [Test] + public async Task UpdateUi_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = + await _controller.UpdateUi(new UpdateUiSettingsRequest(false, "fr"), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetHdhr_Should_Combine_Tuner_Count_And_Uuid() + { + var uuid = Guid.NewGuid(); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(3); + _mediator.Send(Arg.Any(), Arg.Any()).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(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(4); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(uuid); + + IActionResult result = await _controller.UpdateHdhr(new UpdateHdhrSettingsRequest(4), CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(new HdhrSettingsResponseModel(4, uuid)); + } + + [Test] + public async Task UpdateHdhr_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("Tuner count must be greater than zero"))); + + IActionResult result = await _controller.UpdateHdhr(new UpdateHdhrSettingsRequest(0), CancellationToken.None); + + result.ShouldBeOfType(); + } + + 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); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateResolutionRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateResolutionRequest.cs new file mode 100644 index 000000000..a31c28115 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateResolutionRequest.cs @@ -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); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateFFmpegSettingsRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateFFmpegSettingsRequest.cs new file mode 100644 index 000000000..4722292ad --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateFFmpegSettingsRequest.cs @@ -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 + }); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateHdhrSettingsRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateHdhrSettingsRequest.cs new file mode 100644 index 000000000..c08806af5 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateHdhrSettingsRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.HDHR; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateHdhrSettingsRequest(int TunerCount) +{ + public UpdateHDHRTunerCount ToCommand() => new(TunerCount); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateLoggingSettingsRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateLoggingSettingsRequest.cs new file mode 100644 index 000000000..f2321c612 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateLoggingSettingsRequest.cs @@ -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 + }); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdatePlayoutSettingsRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdatePlayoutSettingsRequest.cs new file mode 100644 index 000000000..9562cd445 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdatePlayoutSettingsRequest.cs @@ -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 + }); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateScannerSettingsRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateScannerSettingsRequest.cs new file mode 100644 index 000000000..753797f14 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateScannerSettingsRequest.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Application.Configuration; + +namespace ErsatzTV.Controllers.Api.Requests; + +/// Library scan cadence. is expressed in hours. +public record UpdateScannerSettingsRequest(int LibraryRefreshInterval) +{ + public UpdateLibraryRefreshInterval ToCommand() => new(LibraryRefreshInterval); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateUiSettingsRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateUiSettingsRequest.cs new file mode 100644 index 000000000..15a3f07ae --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateUiSettingsRequest.cs @@ -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 + }); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateXmltvSettingsRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateXmltvSettingsRequest.cs new file mode 100644 index 000000000..31e57b59d --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateXmltvSettingsRequest.cs @@ -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 + }); +} diff --git a/ErsatzTV/Controllers/Api/ResolutionController.cs b/ErsatzTV/Controllers/Api/ResolutionController.cs index 2812747aa..52151922e 100644 --- a/ErsatzTV/Controllers/Api/ResolutionController.cs +++ b/ErsatzTV/Controllers/Api/ResolutionController.cs @@ -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 result = await mediator.Send(new GetResolutionByName(name), cancellationToken); return result.Match>(i => Ok(i), () => NotFound()); } + + [HttpGet("/api/settings/resolutions", Name = "GetResolutions")] + [Tags("Settings")] + [EndpointSummary("Get all resolutions, including custom resolutions")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) + { + List 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 Create( + [Required] [FromBody] + CreateResolutionRequest request, + CancellationToken cancellationToken) + { + Option 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 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 Delete(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new DeleteCustomResolution(id), cancellationToken); + return result.Match( + Some: error => error.ToErrorResult(), + None: () => new NoContentResult()); + } + + private static ResolutionResponseModel ProjectToResponseModel(ResolutionViewModel vm) => + new(vm.Id, vm.Name, vm.Width, vm.Height, vm.IsCustom); } diff --git a/ErsatzTV/Controllers/Api/SettingsController.cs b/ErsatzTV/Controllers/Api/SettingsController.cs new file mode 100644 index 000000000..e36ca48e4 --- /dev/null +++ b/ErsatzTV/Controllers/Api/SettingsController.cs @@ -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 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 UpdateFfmpeg( + [Required] [FromBody] + UpdateFFmpegSettingsRequest request, + CancellationToken cancellationToken) + { + Either 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 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 UpdatePlayout( + [Required] [FromBody] + UpdatePlayoutSettingsRequest request, + CancellationToken cancellationToken) + { + Either 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 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 UpdateXmltv( + [Required] [FromBody] + UpdateXmltvSettingsRequest request, + CancellationToken cancellationToken) + { + Either 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 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 UpdateScanner( + [Required] [FromBody] + UpdateScannerSettingsRequest request, + CancellationToken cancellationToken) + { + Either 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 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 UpdateLogging( + [Required] [FromBody] + UpdateLoggingSettingsRequest request, + CancellationToken cancellationToken) + { + Either 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 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 UpdateUi( + [Required] [FromBody] + UpdateUiSettingsRequest request, + CancellationToken cancellationToken) + { + Either 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 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 UpdateHdhr( + [Required] [FromBody] + UpdateHdhrSettingsRequest request, + CancellationToken cancellationToken) + { + Either 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 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); +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 8a5dcac03..95539c626 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -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 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()); diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index f9d01c35d..92b194f3c 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -3204,6 +3204,165 @@ } } }, + "/api/settings/resolutions": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get all resolutions, including custom resolutions", + "operationId": "GetResolutions", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResolutionResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResolutionResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResolutionResponseModel" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Settings" + ], + "summary": "Create a custom resolution", + "operationId": "CreateResolution", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateResolutionRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResolutionRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateResolutionRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateResolutionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ResolutionResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolutionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ResolutionResponseModel" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/settings/resolutions/{id}": { + "delete": { + "tags": [ + "Settings" + ], + "summary": "Delete a custom resolution", + "operationId": "DeleteResolution", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/schedules": { "get": { "tags": [ @@ -3978,6 +4137,741 @@ } } }, + "/api/settings/ffmpeg": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get global FFmpeg settings", + "operationId": "GetFfmpegSettings", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/FFmpegSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegSettingsResponseModel" + } + } + } + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update global FFmpeg settings", + "operationId": "UpdateFfmpegSettings", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateFFmpegSettingsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFFmpegSettingsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFFmpegSettingsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateFFmpegSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/FFmpegSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegSettingsResponseModel" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/settings/playout": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get global playout settings", + "operationId": "GetPlayoutSettings", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlayoutSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutSettingsResponseModel" + } + } + } + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update global playout settings", + "operationId": "UpdatePlayoutSettings", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdatePlayoutSettingsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePlayoutSettingsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePlayoutSettingsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdatePlayoutSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlayoutSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutSettingsResponseModel" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/settings/xmltv": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get global XMLTV settings", + "operationId": "GetXmltvSettings", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/XmltvSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/XmltvSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/XmltvSettingsResponseModel" + } + } + } + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update global XMLTV settings", + "operationId": "UpdateXmltvSettings", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateXmltvSettingsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateXmltvSettingsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateXmltvSettingsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateXmltvSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/XmltvSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/XmltvSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/XmltvSettingsResponseModel" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/settings/scanner": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get library scan cadence", + "operationId": "GetScannerSettings", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ScannerSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScannerSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ScannerSettingsResponseModel" + } + } + } + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update library scan cadence", + "operationId": "UpdateScannerSettings", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateScannerSettingsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScannerSettingsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScannerSettingsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateScannerSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ScannerSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScannerSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ScannerSettingsResponseModel" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/settings/logging": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get per-area minimum log levels", + "operationId": "GetLoggingSettings", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/LoggingSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoggingSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/LoggingSettingsResponseModel" + } + } + } + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update per-area minimum log levels", + "operationId": "UpdateLoggingSettings", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateLoggingSettingsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLoggingSettingsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLoggingSettingsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateLoggingSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/LoggingSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoggingSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/LoggingSettingsResponseModel" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/settings/ui": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get UI preferences", + "operationId": "GetUiSettings", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/UiSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UiSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UiSettingsResponseModel" + } + } + } + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update UI preferences", + "operationId": "UpdateUiSettings", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateUiSettingsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUiSettingsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUiSettingsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateUiSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/UiSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UiSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UiSettingsResponseModel" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/settings/hdhr": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get HDHomeRun emulation settings", + "operationId": "GetHdhrSettings", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/HdhrSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/HdhrSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/HdhrSettingsResponseModel" + } + } + } + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update HDHomeRun emulation settings", + "operationId": "UpdateHdhrSettings", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateHdhrSettingsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateHdhrSettingsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateHdhrSettingsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateHdhrSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/HdhrSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/HdhrSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/HdhrSettingsResponseModel" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/smart-collections": { "get": { "tags": [ @@ -6129,6 +7023,23 @@ } } }, + "CreateResolutionRequest": { + "required": [ + "width", + "height" + ], + "type": "object", + "properties": { + "width": { + "type": "integer", + "format": "int32" + }, + "height": { + "type": "integer", + "format": "int32" + } + } + }, "CreateScheduleRequest": { "required": [ "name", @@ -6367,6 +7278,85 @@ ], "type": "string" }, + "FFmpegSettingsResponseModel": { + "required": [ + "fFmpegPath", + "fFprobePath", + "defaultFFmpegProfileId", + "preferredAudioLanguageCode", + "useEmbeddedSubtitles", + "extractEmbeddedSubtitles", + "probeForInterlacedFrames", + "saveReports", + "globalWatermarkId", + "globalFallbackFillerId", + "hlsSegmenterIdleTimeout", + "workAheadSegmenterLimit", + "initialSegmentCount", + "hlsDirectOutputFormat", + "defaultMpegTsScript" + ], + "type": "object", + "properties": { + "fFmpegPath": { + "type": "string" + }, + "fFprobePath": { + "type": "string" + }, + "defaultFFmpegProfileId": { + "type": "integer", + "format": "int32" + }, + "preferredAudioLanguageCode": { + "type": "string" + }, + "useEmbeddedSubtitles": { + "type": "boolean" + }, + "extractEmbeddedSubtitles": { + "type": "boolean" + }, + "probeForInterlacedFrames": { + "type": "boolean" + }, + "saveReports": { + "type": "boolean" + }, + "globalWatermarkId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "globalFallbackFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "hlsSegmenterIdleTimeout": { + "type": "integer", + "format": "int32" + }, + "workAheadSegmenterLimit": { + "type": "integer", + "format": "int32" + }, + "initialSegmentCount": { + "type": "integer", + "format": "int32" + }, + "hlsDirectOutputFormat": { + "$ref": "#/components/schemas/OutputFormatKind" + }, + "defaultMpegTsScript": { + "type": "string" + } + } + }, "FillerKind": { "enum": [ "None", @@ -6602,6 +7592,23 @@ ], "type": "string" }, + "HdhrSettingsResponseModel": { + "required": [ + "tunerCount", + "uuid" + ], + "type": "object", + "properties": { + "tunerCount": { + "type": "integer", + "format": "int32" + }, + "uuid": { + "type": "string", + "format": "uuid" + } + } + }, "HealthCheckResponseModel": { "required": [ "title", @@ -6820,6 +7827,48 @@ } } }, + "LogEventLevel": { + "enum": [ + "Verbose", + "Debug", + "Information", + "Warning", + "Error", + "Fatal" + ], + "type": "string" + }, + "LoggingSettingsResponseModel": { + "required": [ + "defaultMinimumLogLevel", + "scanningMinimumLogLevel", + "schedulingMinimumLogLevel", + "searchingMinimumLogLevel", + "streamingMinimumLogLevel", + "httpMinimumLogLevel" + ], + "type": "object", + "properties": { + "defaultMinimumLogLevel": { + "$ref": "#/components/schemas/LogEventLevel" + }, + "scanningMinimumLogLevel": { + "$ref": "#/components/schemas/LogEventLevel" + }, + "schedulingMinimumLogLevel": { + "$ref": "#/components/schemas/LogEventLevel" + }, + "searchingMinimumLogLevel": { + "$ref": "#/components/schemas/LogEventLevel" + }, + "streamingMinimumLogLevel": { + "$ref": "#/components/schemas/LogEventLevel" + }, + "httpMinimumLogLevel": { + "$ref": "#/components/schemas/LogEventLevel" + } + } + }, "MarathonGroupBy": { "enum": [ "None", @@ -7091,6 +8140,18 @@ ], "type": "string" }, + "OutputFormatKind": { + "enum": [ + "None", + "Mkv", + "MpegTs", + "Mp4", + "Hls", + "HlsMp4", + "Nut" + ], + "type": "string" + }, "PagedLibraryBrowseItemsResponseModel": { "required": [ "totalCount", @@ -7394,6 +8455,27 @@ ], "type": "string" }, + "PlayoutSettingsResponseModel": { + "required": [ + "daysToBuild", + "skipMissingItems", + "scriptedScheduleTimeoutSeconds" + ], + "type": "object", + "properties": { + "daysToBuild": { + "type": "integer", + "format": "int32" + }, + "skipMissingItems": { + "type": "boolean" + }, + "scriptedScheduleTimeoutSeconds": { + "type": "integer", + "format": "int32" + } + } + }, "ProblemDetails": { "type": "object", "properties": { @@ -7732,6 +8814,36 @@ } } }, + "ResolutionResponseModel": { + "required": [ + "id", + "name", + "width", + "height", + "isCustom" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "width": { + "type": "integer", + "format": "int32" + }, + "height": { + "type": "integer", + "format": "int32" + }, + "isCustom": { + "type": "boolean" + } + } + }, "ResolutionViewModel": { "required": [ "id", @@ -7773,6 +8885,18 @@ ], "type": "string" }, + "ScannerSettingsResponseModel": { + "required": [ + "libraryRefreshInterval" + ], + "type": "object", + "properties": { + "libraryRefreshInterval": { + "type": "integer", + "format": "int32" + } + } + }, "ScanShowRequest": { "required": [ "showTitle" @@ -8132,6 +9256,21 @@ ], "type": "string" }, + "UiSettingsResponseModel": { + "required": [ + "isDarkMode", + "language" + ], + "type": "object", + "properties": { + "isDarkMode": { + "type": "boolean" + }, + "language": { + "type": "string" + } + } + }, "UpdateChannelRequest": { "required": [ "name", @@ -8617,6 +9756,173 @@ } } }, + "UpdateFFmpegSettingsRequest": { + "required": [ + "fFmpegPath", + "fFprobePath", + "defaultFFmpegProfileId", + "preferredAudioLanguageCode", + "useEmbeddedSubtitles", + "extractEmbeddedSubtitles", + "probeForInterlacedFrames", + "saveReports", + "globalWatermarkId", + "globalFallbackFillerId", + "hlsSegmenterIdleTimeout", + "workAheadSegmenterLimit", + "initialSegmentCount", + "hlsDirectOutputFormat", + "defaultMpegTsScript" + ], + "type": "object", + "properties": { + "fFmpegPath": { + "type": [ + "null", + "string" + ] + }, + "fFprobePath": { + "type": [ + "null", + "string" + ] + }, + "defaultFFmpegProfileId": { + "type": "integer", + "format": "int32" + }, + "preferredAudioLanguageCode": { + "type": [ + "null", + "string" + ] + }, + "useEmbeddedSubtitles": { + "type": "boolean" + }, + "extractEmbeddedSubtitles": { + "type": "boolean" + }, + "probeForInterlacedFrames": { + "type": "boolean" + }, + "saveReports": { + "type": "boolean" + }, + "globalWatermarkId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "globalFallbackFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "hlsSegmenterIdleTimeout": { + "type": "integer", + "format": "int32" + }, + "workAheadSegmenterLimit": { + "type": "integer", + "format": "int32" + }, + "initialSegmentCount": { + "type": "integer", + "format": "int32" + }, + "hlsDirectOutputFormat": { + "$ref": "#/components/schemas/OutputFormatKind" + }, + "defaultMpegTsScript": { + "type": [ + "null", + "string" + ] + } + } + }, + "UpdateHdhrSettingsRequest": { + "required": [ + "tunerCount" + ], + "type": "object", + "properties": { + "tunerCount": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdateLoggingSettingsRequest": { + "required": [ + "defaultMinimumLogLevel", + "scanningMinimumLogLevel", + "schedulingMinimumLogLevel", + "searchingMinimumLogLevel", + "streamingMinimumLogLevel", + "httpMinimumLogLevel" + ], + "type": "object", + "properties": { + "defaultMinimumLogLevel": { + "$ref": "#/components/schemas/LogEventLevel" + }, + "scanningMinimumLogLevel": { + "$ref": "#/components/schemas/LogEventLevel" + }, + "schedulingMinimumLogLevel": { + "$ref": "#/components/schemas/LogEventLevel" + }, + "searchingMinimumLogLevel": { + "$ref": "#/components/schemas/LogEventLevel" + }, + "streamingMinimumLogLevel": { + "$ref": "#/components/schemas/LogEventLevel" + }, + "httpMinimumLogLevel": { + "$ref": "#/components/schemas/LogEventLevel" + } + } + }, + "UpdatePlayoutSettingsRequest": { + "required": [ + "daysToBuild", + "skipMissingItems", + "scriptedScheduleTimeoutSeconds" + ], + "type": "object", + "properties": { + "daysToBuild": { + "type": "integer", + "format": "int32" + }, + "skipMissingItems": { + "type": "boolean" + }, + "scriptedScheduleTimeoutSeconds": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdateScannerSettingsRequest": { + "required": [ + "libraryRefreshInterval" + ], + "type": "object", + "properties": { + "libraryRefreshInterval": { + "type": "integer", + "format": "int32" + } + } + }, "UpdateScheduleRequest": { "required": [ "name", @@ -8672,6 +9978,44 @@ } } }, + "UpdateUiSettingsRequest": { + "required": [ + "isDarkMode", + "language" + ], + "type": "object", + "properties": { + "isDarkMode": { + "type": "boolean" + }, + "language": { + "type": [ + "null", + "string" + ] + } + } + }, + "UpdateXmltvSettingsRequest": { + "required": [ + "daysToBuild", + "timeZone", + "blockBehavior" + ], + "type": "object", + "properties": { + "daysToBuild": { + "type": "integer", + "format": "int32" + }, + "timeZone": { + "$ref": "#/components/schemas/XmltvTimeZone" + }, + "blockBehavior": { + "$ref": "#/components/schemas/XmltvBlockBehavior" + } + } + }, "VaapiDriver": { "enum": [ "Default", @@ -8791,6 +10135,40 @@ "format": "int32" } } + }, + "XmltvBlockBehavior": { + "enum": [ + "SplitTimeEvenly", + "UseActualTimes" + ], + "type": "string" + }, + "XmltvSettingsResponseModel": { + "required": [ + "daysToBuild", + "timeZone", + "blockBehavior" + ], + "type": "object", + "properties": { + "daysToBuild": { + "type": "integer", + "format": "int32" + }, + "timeZone": { + "$ref": "#/components/schemas/XmltvTimeZone" + }, + "blockBehavior": { + "$ref": "#/components/schemas/XmltvBlockBehavior" + } + } + }, + "XmltvTimeZone": { + "enum": [ + "Local", + "Utc" + ], + "type": "string" } } }, @@ -8837,6 +10215,9 @@ { "name": "Resolution" }, + { + "name": "Settings" + }, { "name": "Schedules" }, From 5f1d3e4d96c1661a037cd0e4719e2a3ac2f492c6 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 08:22:37 +0200 Subject: [PATCH 2/6] design: Settings screen prototype + handoff bundle (#93) templates/chicorytv-admin/Settings.jsx wired into app.html; design_handoff_settings/ carries the implementation spec + API mapping. Synced to the Claude Design project via DesignSync. Co-Authored-By: Claude Fable 5 --- .../design_handoff_settings/README.md | 115 +++++ .../design_handoff_settings/Settings.jsx | 416 ++++++++++++++++++ .../templates/chicorytv-admin/Settings.jsx | 416 ++++++++++++++++++ .../templates/chicorytv-admin/app.html | 4 +- 4 files changed, 950 insertions(+), 1 deletion(-) create mode 100644 design-system/design_handoff_settings/README.md create mode 100644 design-system/design_handoff_settings/Settings.jsx create mode 100644 design-system/templates/chicorytv-admin/Settings.jsx diff --git a/design-system/design_handoff_settings/README.md b/design-system/design_handoff_settings/README.md new file mode 100644 index 000000000..1e04ec187 --- /dev/null +++ b/design-system/design_handoff_settings/README.md @@ -0,0 +1,115 @@ +# Handoff: ChicoryTV — Settings + +## Overview +The **Settings** screen replaces ErsatzTV's seven legacy Blazor settings pages (FFmpeg, HDHR, +Playout, XMLTV, Scanner, Logging, UI) with **one screen**: a left sub-nav rail selects a settings +group, the right pane shows that group as dense "system settings" rows, and a **floating save bar** +appears whenever there are unsaved changes. It ships inside the ChicoryTV admin shell. + +## About the design files +`Settings.jsx` here is a **design reference** (same file as `templates/chicorytv-admin/Settings.jsx`, +running on the UI-kit's React primitives + CSS custom-property tokens) — recreate it in the target +codebase (`web/` React SPA) using its established components and patterns, not by shipping this file. + +## Backend contract (agreed scope, issue #93) +Each pane maps 1:1 to a thin REST wrapper over an existing MediatR handler pair: + +| Pane | Endpoint | Fields | +|---|---|---| +| General | `GET/PUT /api/settings/ui` | theme (Dark/Light), language (culture) | +| Streaming | `GET/PUT /api/settings/ffmpeg` | ffmpeg/ffprobe paths, default profile id, preferred audio language, 4 bool flags, global watermark id, global fallback filler id, HLS idle timeout / work-ahead limit / initial segments, HLS-Direct output format, default MPEG-TS script | +| Streaming → Custom resolutions | `GET /api/settings/resolutions` + `POST`/`DELETE` custom resolutions | width × height list, custom-only deletable | +| Streaming → FFmpeg profiles | existing `GET /api/ffmpeg/profiles` | read-only list + Default badge; editing stays in legacy UI (callout) | +| Playout | `GET/PUT /api/settings/playout` | days to build, skip missing items, scripted schedule timeout | +| Guide (XMLTV) | `GET/PUT /api/settings/xmltv` | days to build, time zone (Local/UTC), block behavior (SplitTimeEvenly/UseActualTimes) | +| Scanner | `GET/PUT /api/settings/scanner` | library refresh interval hours (0 = disabled → warning callout) | +| Logging | `GET/PUT /api/settings/logging` | 6 Serilog min levels: Default, Scanning, Scheduling, Searching, Streaming, HTTP | +| System → HDHR | `GET/PUT /api/settings/hdhr` | tuner count (writable), device UUID (read-only + copy) | +| System → Media sources | existing `GET /api/media-sources` | read-only list + "Legacy UI" callout | +| System → About | existing `GET /api/version`, `GET /api/health` | version, health summary + link to Dashboard | + +## Fidelity +**High-fidelity.** Colors, type, spacing, radii and interactions are resolved; all values are token +references (`var(--…)`) from the design system. Recreate with the codebase's real component library. + +## Layout +Full-height flex row under the admin shell TopBar (title "Settings"): + +- **Sub-nav rail** — 224px, `flex: 0 0 auto`, hairline right border, `padding: 12px 8px`, + scrolls independently. One button per section: icon (16px, accent when active) + label + (`--text-sm` medium; primary when active, else secondary) + one-line hint (`--text-2xs`, + disabled color, ellipsized). Active = `--ctv-accent-soft` background, radius `--radius-sm`; + hover = `--ctv-surface-2`. Uses `.ctv-press`. Sections (id · label · lucide icon · hint): + 1. `general` · General · SlidersHorizontal · "Theme & language" + 2. `streaming` · Streaming · Clapperboard · "FFmpeg & transcoding" + 3. `playout` · Playout · ListVideo · "Build defaults" + 4. `xmltv` · Guide (XMLTV) · CalendarDays · "EPG output" + 5. `scanner` · Scanner · Radar · "Library refresh" + 6. `logging` · Logging · ScrollText · "Log levels" + 7. `system` · System · Server · "HDHR, sources, about" + +- **Pane** — fills the rest; scroll container padded `20px 24px 96px` (bottom room for the save + bar). Content is a `max-width: 760px` column, `gap: 16`, entering with `ctv-fade-in` 240ms. + Pane header: title (`--text-lg` semibold) + subtitle (`--text-sm`, secondary, 1.45 line-height). + +## Row primitive (the core pattern) +Settings are flush rows inside `Card padded={false}`; each row: +`display:flex; align-items:center; gap:20; padding:13px 16px;` hairline top border between rows. +Left: label (`--text-sm` medium, primary) + optional help line (`--text-xs`, disabled, 1.45). +Right: fixed-width control slot (`flex: 0 0 260px` default; 320px for path inputs, 180px for +log-level selects, 340px for the UUID row), right-justified. Controls are `size="sm"`: +`Select fullWidth`, `Switch`, `Input`. + +Specialized controls: +- **Path input** — mono font, full width of slot, trailing validity icon: `CircleCheck` in + `--status-ok` when valid, `CircleAlert` in `--status-error` with a "File not found" tooltip when not. +- **Number input** — `type="number"`, 120px, mono, trailing unit hint (`sec`, `days`, `hours`, + `tuners`, `sessions`, `segments`) in `--text-2xs` disabled color. + +## Floating save bar (screen-level dirty state) +One draft state for the whole screen (all panes share it — switching sections keeps edits). +When ≥1 field differs from saved values, a pill bar floats bottom-center of the pane +(absolute, `padding-bottom: 18px`): `--surface-raised` bg, `--border-control` border, +`--radius-pill`, `--shadow-pop`, `ctv-fade-in` 200ms, `.ctv-lift`. Contents: +"N unsaved change(s)" (`--text-sm` medium) · 1px hairline divider · **Discard** (ghost, resets +draft) · **Save changes** (primary, Check icon). After save, the bar swaps to a transient +confirmation — `CircleCheck` in `--status-ok` + "Settings saved" — for ~1.8s, then disappears. + +## Pane details +- **General**: one card — Theme select (Dark/Light), Language select. Subtitle notes these apply + to the *legacy* web UI (ChicoryTV theming is the shell's theme switcher, not a server setting). +- **Streaming**: five cards — + 1. *FFmpeg*: both path inputs, default profile select, preferred audio language select, four + switches (use embedded subtitles, extract embedded subtitles, probe interlaced, save reports). + 2. *Global defaults*: watermark select, fallback filler select (both with "(none)" option). + 3. *HLS sessions*: idle timeout, work-ahead limit, initial segments (number inputs) + HLS-Direct + output format select (MPEG-TS/MP4/MKV) + default MPEG-TS script select. + 4. *Custom resolutions* (flush list): rows of `W × H` (mono) + `custom` Tag + ghost trash + IconButton; footer add-row on `--ctv-bg-sunken`: width/height number inputs + "Add resolution" + secondary button (disabled until both filled). + 5. *FFmpeg profiles* (flush list, read-only): Cpu icon (accent) + name + **Default** accent Badge on + the default profile; card action shows a count Badge; footer = Legacy-UI callout (see below). +- **Playout**: one card — days to build, skip missing (switch), scripted timeout. +- **Guide (XMLTV)**: one card — days to build, time zone select, block behavior select. +- **Scanner**: one card — refresh interval hours. When value is 0, a warning callout appears under + the card: `--status-warn-soft` bg, TriangleAlert icon, "Automatic scanning is disabled…". +- **Logging**: one card, six rows (Default/Scanning/Scheduling/Searching/Streaming/HTTP), each a + level select (Verbose…Fatal, 180px slot) with a per-category help line. +- **System**: three cards — + 1. *HDHomeRun*: tuner count (writable number input), device UUID (read-only mono + Copy IconButton). + 2. *Media sources* (flush, read-only): kind icon (HardDrive for Local, Cast otherwise) + name + + mono host detail + StatusDot (ok/warn) + last-scan text; footer Legacy-UI callout. + 3. *About*: version (mono), health checks summary + "Open Dashboard" secondary button (ArrowRight). + +## Legacy-UI callout (reusable) +For anything visible-but-not-editable here: a row with `--ctv-surface-2` bg, **dashed** +`--border-control` border, `--radius-sm`; ExternalLink icon (disabled color) + explanation +(`--text-xs`, secondary) + a neutral **Legacy UI** Badge pinned right. + +## Interaction notes +- Section switching is instant (no route change needed in the prototype; the SPA should use its + routing so sections are linkable, e.g. `/app/settings/streaming`). +- Draft edits survive section switches; Discard restores all panes at once. +- Number inputs keep string state in the prototype; the SPA should validate (ints ≥ 0) before save. +- Reduced motion: all animation comes from `.ctv-lift`/`.ctv-press`/`ctv-fade-in`, already gated by + `prefers-reduced-motion` in the kit. diff --git a/design-system/design_handoff_settings/Settings.jsx b/design-system/design_handoff_settings/Settings.jsx new file mode 100644 index 000000000..962908a1a --- /dev/null +++ b/design-system/design_handoff_settings/Settings.jsx @@ -0,0 +1,416 @@ +// Settings screen — GROUND-UP: left sub-nav rail + focused form panes, one pane +// per settings group (mirrors /api/settings/*). Dense "system settings" rows: +// label + help text left, control right. A floating save bar appears when dirty. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Card, Button, IconButton, Badge, Input, Select, Switch, Tooltip, StatusDot, Tag } = NS; + const Ico = window.Ico; + const D = window.CTV_DATA; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const SECTIONS = [ + { id: "general", label: "General", icon: "SlidersHorizontal", hint: "Theme & language" }, + { id: "streaming",label: "Streaming", icon: "Clapperboard", hint: "FFmpeg & transcoding" }, + { id: "playout", label: "Playout", icon: "ListVideo", hint: "Build defaults" }, + { id: "xmltv", label: "Guide (XMLTV)",icon: "CalendarDays", hint: "EPG output" }, + { id: "scanner", label: "Scanner", icon: "Radar", hint: "Library refresh" }, + { id: "logging", label: "Logging", icon: "ScrollText", hint: "Log levels" }, + { id: "system", label: "System", icon: "Server", hint: "HDHR, sources, about" }, + ]; + + const PROFILES = ["1080p H.264", "1080p HEVC", "720p H.264", "480p H.264"]; + const LOG_LEVELS = ["Verbose", "Debug", "Information", "Warning", "Error", "Fatal"]; + const DEFAULTS = { + theme: "Dark", language: "English (en-US)", + ffmpegPath: "/usr/bin/ffmpeg", ffprobePath: "/usr/bin/ffprobe", + defaultProfile: "1080p H.264", preferredAudioLanguage: "English (eng)", + useEmbeddedSubtitles: true, extractEmbeddedSubtitles: false, + probeInterlaced: true, saveReports: false, + globalWatermark: "(none)", globalFallbackFiller: "Retro Bumpers", + hlsIdleTimeout: "60", hlsWorkAhead: "1", hlsInitialSegments: "1", + hlsDirectFormat: "MPEG-TS", mpegTsScript: "(default template)", + playoutDays: "2", skipMissing: true, scriptedTimeout: "30", + xmltvDays: "2", xmltvTimeZone: "Local", xmltvBlockBehavior: "Split time evenly", + refreshHours: "6", + logDefault: "Information", logScanning: "Information", logScheduling: "Information", + logSearching: "Information", logStreaming: "Information", logHttp: "Warning", + tunerCount: "2", + }; + + /* ---------- shared row primitives ---------- */ + + function Pane({ title, subtitle, children }) { + return ( +
+
+
{title}
+ {subtitle &&
{subtitle}
} +
+ {children} +
+ ); + } + + // One setting: label + help left, control right. Flush rows inside Card padded={false}. + function Row({ label, help, children, first, control = 260 }) { + return ( +
+
+
{label}
+ {help &&
{help}
} +
+
{children}
+
+ ); + } + + function LegacyCallout({ children }) { + return ( +
+ + {children} + Legacy UI +
+ ); + } + + function PathInput({ value, onChange }) { + const ok = value.startsWith("/"); + return ( + onChange(e.target.value)} + style={mono} + trailing={ok + ? + : } /> + ); + } + + function NumInput({ value, onChange, unit, w = 120 }) { + return ( + onChange(e.target.value)} + style={{ width: w, ...mono }} + trailing={unit && {unit}} /> + ); + } + + /* ---------- panes ---------- */ + + function GeneralPane({ v, set }) { + return ( + + + + set("language", e.target.value)} + options={["English (en-US)", "English (en-GB)", "Deutsch (de)", "Español (es)", "Français (fr)", "Nederlands (nl)"]} /> + + + + ); + } + + function StreamingPane({ v, set }) { + const [resW, setResW] = React.useState(""); + const [resH, setResH] = React.useState(""); + const [customRes, setCustomRes] = React.useState([{ id: 1, w: 1920, h: 820 }, { id: 2, w: 2560, h: 1080 }]); + return ( + + + + set("ffmpegPath", x)} /> + + + set("ffprobePath", x)} /> + + + set("preferredAudioLanguage", e.target.value)} + options={["English (eng)", "Japanese (jpn)", "Spanish (spa)", "French (fra)", "German (deu)"]} /> + + + set("useEmbeddedSubtitles", x)} /> + + + set("extractEmbeddedSubtitles", x)} /> + + + set("probeInterlaced", x)} /> + + + set("saveReports", x)} /> + + + + + + set("globalFallbackFiller", e.target.value)} + options={["(none)", "Retro Bumpers", "Ad Break Pool", "Test Pattern"]} /> + + + + + + set("hlsIdleTimeout", x)} unit="sec" /> + + + set("hlsWorkAhead", x)} unit="sessions" /> + + + set("hlsInitialSegments", x)} unit="segments" /> + + + set("mpegTsScript", e.target.value)} options={["(default template)", "custom-remux.sh"]} /> + + + + + {customRes.map((r, i) => ( +
+ + {r.w} × {r.h} + custom + setCustomRes(customRes.filter((x) => x.id !== r.id))}> + + +
+ ))} +
+ setResW(e.target.value)} style={{ width: 96, ...mono }} /> + × + setResH(e.target.value)} style={{ width: 96, ...mono }} /> + + +
+
+ + {PROFILES.length} profiles}> + {PROFILES.map((p, i) => ( +
+ + {p} + {p === v.defaultProfile && Default} + {i + 1} ch +
+ ))} +
+ Profile editing (hardware acceleration, bitrates, tonemapping) stays in the legacy UI for now. +
+
+
+ ); + } + + function PlayoutPane({ v, set }) { + return ( + + + + set("playoutDays", x)} unit="days" /> + + + set("skipMissing", x)} /> + + + set("scriptedTimeout", x)} unit="sec" /> + + + + ); + } + + function XmltvPane({ v, set }) { + return ( + + + + set("xmltvDays", x)} unit="days" /> + + + set("xmltvBlockBehavior", e.target.value)} options={["Split time evenly", "Use actual times"]} /> + + + + ); + } + + function ScannerPane({ v, set }) { + return ( + + + + set("refreshHours", x)} unit="hours" /> + + + {v.refreshHours === "0" && ( +
+ + + Automatic scanning is disabled — libraries only update when scanned manually. + +
+ )} +
+ ); + } + + function LoggingPane({ v, set }) { + const CATS = [ + { key: "logDefault", label: "Default", help: "Everything without a more specific category." }, + { key: "logScanning", label: "Scanning", help: "Library scans and metadata refresh." }, + { key: "logScheduling", label: "Scheduling", help: "Playout builds and schedule evaluation." }, + { key: "logSearching", label: "Searching", help: "Search index and queries." }, + { key: "logStreaming", label: "Streaming", help: "FFmpeg sessions and IPTV requests." }, + { key: "logHttp", label: "HTTP", help: "ASP.NET request logging." }, + ]; + return ( + + + {CATS.map((c, i) => ( + + onChange(e.target.value)} + style={mono} + trailing={ok + ? + : } /> + ); + } + + function NumInput({ value, onChange, unit, w = 120 }) { + return ( + onChange(e.target.value)} + style={{ width: w, ...mono }} + trailing={unit && {unit}} /> + ); + } + + /* ---------- panes ---------- */ + + function GeneralPane({ v, set }) { + return ( + + + + set("language", e.target.value)} + options={["English (en-US)", "English (en-GB)", "Deutsch (de)", "Español (es)", "Français (fr)", "Nederlands (nl)"]} /> + + + + ); + } + + function StreamingPane({ v, set }) { + const [resW, setResW] = React.useState(""); + const [resH, setResH] = React.useState(""); + const [customRes, setCustomRes] = React.useState([{ id: 1, w: 1920, h: 820 }, { id: 2, w: 2560, h: 1080 }]); + return ( + + + + set("ffmpegPath", x)} /> + + + set("ffprobePath", x)} /> + + + set("preferredAudioLanguage", e.target.value)} + options={["English (eng)", "Japanese (jpn)", "Spanish (spa)", "French (fra)", "German (deu)"]} /> + + + set("useEmbeddedSubtitles", x)} /> + + + set("extractEmbeddedSubtitles", x)} /> + + + set("probeInterlaced", x)} /> + + + set("saveReports", x)} /> + + + + + + set("globalFallbackFiller", e.target.value)} + options={["(none)", "Retro Bumpers", "Ad Break Pool", "Test Pattern"]} /> + + + + + + set("hlsIdleTimeout", x)} unit="sec" /> + + + set("hlsWorkAhead", x)} unit="sessions" /> + + + set("hlsInitialSegments", x)} unit="segments" /> + + + set("mpegTsScript", e.target.value)} options={["(default template)", "custom-remux.sh"]} /> + + + + + {customRes.map((r, i) => ( +
+ + {r.w} × {r.h} + custom + setCustomRes(customRes.filter((x) => x.id !== r.id))}> + + +
+ ))} +
+ setResW(e.target.value)} style={{ width: 96, ...mono }} /> + × + setResH(e.target.value)} style={{ width: 96, ...mono }} /> + + +
+
+ + {PROFILES.length} profiles}> + {PROFILES.map((p, i) => ( +
+ + {p} + {p === v.defaultProfile && Default} + {i + 1} ch +
+ ))} +
+ Profile editing (hardware acceleration, bitrates, tonemapping) stays in the legacy UI for now. +
+
+
+ ); + } + + function PlayoutPane({ v, set }) { + return ( + + + + set("playoutDays", x)} unit="days" /> + + + set("skipMissing", x)} /> + + + set("scriptedTimeout", x)} unit="sec" /> + + + + ); + } + + function XmltvPane({ v, set }) { + return ( + + + + set("xmltvDays", x)} unit="days" /> + + + set("xmltvBlockBehavior", e.target.value)} options={["Split time evenly", "Use actual times"]} /> + + + + ); + } + + function ScannerPane({ v, set }) { + return ( + + + + set("refreshHours", x)} unit="hours" /> + + + {v.refreshHours === "0" && ( +
+ + + Automatic scanning is disabled — libraries only update when scanned manually. + +
+ )} +
+ ); + } + + function LoggingPane({ v, set }) { + const CATS = [ + { key: "logDefault", label: "Default", help: "Everything without a more specific category." }, + { key: "logScanning", label: "Scanning", help: "Library scans and metadata refresh." }, + { key: "logScheduling", label: "Scheduling", help: "Playout builds and schedule evaluation." }, + { key: "logSearching", label: "Searching", help: "Search index and queries." }, + { key: "logStreaming", label: "Streaming", help: "FFmpeg sessions and IPTV requests." }, + { key: "logHttp", label: "HTTP", help: "ASP.NET request logging." }, + ]; + return ( + + + {CATS.map((c, i) => ( + +