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 ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using ApiXmltvBlockBehavior = ErsatzTV.Core.Api.Settings.XmltvBlockBehavior; using ApiXmltvTimeZone = ErsatzTV.Core.Api.Settings.XmltvTimeZone; using VmXmltvBlockBehavior = ErsatzTV.Application.Configuration.XmltvBlockBehavior; using VmXmltvTimeZone = ErsatzTV.Application.Configuration.XmltvTimeZone; namespace ErsatzTV.Controllers.Api; [ApiController] [EndpointGroupName("general")] [RequiresAuthentication] public class SettingsController(IMediator mediator) : ControllerBase { // FFmpeg settings [HttpGet("/api/v1/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/v1/settings/ffmpeg", Name = "UpdateFfmpegSettings")] [Tags("Settings")] [EndpointSummary("Update global FFmpeg settings")] [ProducesResponseType(typeof(FFmpegSettingsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [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/v1/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/v1/settings/playout", Name = "UpdatePlayoutSettings")] [Tags("Settings")] [EndpointSummary("Update global playout settings")] [ProducesResponseType(typeof(PlayoutSettingsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [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/v1/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/v1/settings/xmltv", Name = "UpdateXmltvSettings")] [Tags("Settings")] [EndpointSummary("Update global XMLTV settings")] [ProducesResponseType(typeof(XmltvSettingsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [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)); }); } // IPTV settings [HttpGet("/api/v1/settings/iptv", Name = "GetIptvSettings")] [Tags("Settings")] [EndpointSummary("Get IPTV output settings")] [ProducesResponseType(typeof(IptvSettingsResponseModel), StatusCodes.Status200OK)] public async Task GetIptv(CancellationToken cancellationToken) { IptvSettingsViewModel settings = await mediator.Send(new GetIptvSettings(), cancellationToken); return ProjectToResponseModel(settings); } [HttpPut("/api/v1/settings/iptv", Name = "UpdateIptvSettings")] [Tags("Settings")] [EndpointSummary("Update IPTV output settings")] [ProducesResponseType(typeof(IptvSettingsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task UpdateIptv( [Required] [FromBody] UpdateIptvSettingsRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { IptvSettingsViewModel settings = await mediator.Send(new GetIptvSettings(), cancellationToken); return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings)); }); } // Scanner settings [HttpGet("/api/v1/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/v1/settings/scanner", Name = "UpdateScannerSettings")] [Tags("Settings")] [EndpointSummary("Update library scan cadence")] [ProducesResponseType(typeof(ScannerSettingsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [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/v1/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/v1/settings/logging", Name = "UpdateLoggingSettings")] [Tags("Settings")] [EndpointSummary("Update per-area minimum log levels")] [ProducesResponseType(typeof(LoggingSettingsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [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/v1/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/v1/settings/ui", Name = "UpdateUiSettings")] [Tags("Settings")] [EndpointSummary("Update UI preferences")] [ProducesResponseType(typeof(UiSettingsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [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/v1/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/v1/settings/hdhr", Name = "UpdateHdhrSettings")] [Tags("Settings")] [EndpointSummary("Update HDHomeRun emulation settings")] [ProducesResponseType(typeof(HdhrSettingsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [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 IptvSettingsResponseModel ProjectToResponseModel(IptvSettingsViewModel vm) => new(vm.BaseUrl); private static XmltvSettingsResponseModel ProjectToResponseModel(XmltvSettingsViewModel vm) => new(vm.DaysToBuild, ToApiTimeZone(vm.TimeZone), ToApiBlockBehavior(vm.BlockBehavior)); private static ApiXmltvTimeZone ToApiTimeZone(VmXmltvTimeZone timeZone) => timeZone switch { VmXmltvTimeZone.Local => ApiXmltvTimeZone.Local, VmXmltvTimeZone.Utc => ApiXmltvTimeZone.Utc, _ => throw new ArgumentOutOfRangeException(nameof(timeZone), timeZone, null) }; private static ApiXmltvBlockBehavior ToApiBlockBehavior(VmXmltvBlockBehavior blockBehavior) => blockBehavior switch { VmXmltvBlockBehavior.SplitTimeEvenly => ApiXmltvBlockBehavior.SplitTimeEvenly, VmXmltvBlockBehavior.UseActualTimes => ApiXmltvBlockBehavior.UseActualTimes, _ => throw new ArgumentOutOfRangeException(nameof(blockBehavior), blockBehavior, null) }; 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); }