Merge pull request 'feat(web): Settings screen (#93)' (#138) from feat/93-settings into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled

This commit was merged in pull request #138.
This commit is contained in:
2026-07-07 07:34:47 +00:00
41 changed files with 6473 additions and 21 deletions
@@ -1,5 +1,4 @@
#nullable enable
using Dapper;
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
@@ -63,15 +62,16 @@ public class GetAllMediaSourcesForApiHandler(
TvContext dbContext,
CancellationToken cancellationToken)
{
IEnumerable<LibraryItemCount> counts = await dbContext.Connection.QueryAsync<LibraryItemCount>(
new CommandDefinition(
@"SELECT LP.LibraryId AS LibraryId, COUNT(*) AS Count
FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
GROUP BY LP.LibraryId",
cancellationToken: cancellationToken));
// EF instead of Dapper: with zero rows, Microsoft.Data.Sqlite reports the
// COUNT(*) column as BLOB, so Dapper builds an incompatible deserializer
// and every fresh (empty) database returned 500 from this endpoint.
var counts = await dbContext.MediaItems
.AsNoTracking()
.GroupBy(mi => mi.LibraryPath.LibraryId)
.Select(g => new { LibraryId = g.Key, Count = g.Count() })
.ToListAsync(cancellationToken);
return counts.ToDictionary(c => (int)c.LibraryId, c => (int)c.Count);
return counts.ToDictionary(c => c.LibraryId, c => c.Count);
}
private static async Task<Dictionary<int, string>> GetConnectionAddresses(
@@ -143,6 +143,4 @@ public class GetAllMediaSourcesForApiHandler(
EmbyMediaSource emby => emby.ServerName,
_ => "Local"
};
private sealed record LibraryItemCount(long LibraryId, long Count);
}
@@ -1,6 +1,7 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -18,23 +19,28 @@ public class DeleteCustomResolutionHandler : IRequestHandler<DeleteCustomResolut
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Resolution> maybeResolution = await dbContext.Resolutions
Option<Resolution> maybeAnyResolution = await dbContext.Resolutions
.AsNoTracking()
.SelectOneAsync(p => p.Id, p => p.Id == request.ResolutionId && p.IsCustom == true, cancellationToken);
.SelectOneAsync(p => p.Id, p => p.Id == request.ResolutionId, cancellationToken);
foreach (Resolution resolution in maybeResolution)
foreach (Resolution existingResolution in maybeAnyResolution)
{
if (!existingResolution.IsCustom)
{
return BaseError.New($"Resolution {request.ResolutionId} is not a custom resolution.");
}
// reset any ffmpeg profiles using this resolution to 1920x1080
await dbContext.Connection.ExecuteAsync(
@"UPDATE FFmpegProfile SET ResolutionId = 3 WHERE ResolutionId = @ResolutionId",
new { request.ResolutionId });
dbContext.Resolutions.Remove(resolution);
dbContext.Resolutions.Remove(existingResolution);
await dbContext.SaveChangesAsync(cancellationToken);
}
return maybeResolution.IsNone
? BaseError.New($"Resolution {request.ResolutionId} does not exist.")
return maybeAnyResolution.IsNone
? new NotFoundError($"Resolution {request.ResolutionId} does not exist.")
: Option<BaseError>.None;
}
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record FFmpegSettingsResponseModel(
string FFmpegPath,
string FFprobePath,
int DefaultFFmpegProfileId,
string PreferredAudioLanguageCode,
bool UseEmbeddedSubtitles,
bool ExtractEmbeddedSubtitles,
bool ProbeForInterlacedFrames,
bool SaveReports,
int? GlobalWatermarkId,
int? GlobalFallbackFillerId,
int HlsSegmenterIdleTimeout,
int WorkAheadSegmenterLimit,
int InitialSegmentCount,
[property: JsonConverter(typeof(JsonStringEnumConverter<OutputFormatKind>))]
OutputFormatKind HlsDirectOutputFormat,
string DefaultMpegTsScript);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record HdhrSettingsResponseModel(int TunerCount, Guid Uuid);
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
using Serilog.Events;
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record LoggingSettingsResponseModel(
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel DefaultMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel ScanningMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel SchedulingMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel SearchingMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel StreamingMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel HttpMinimumLogLevel);
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record PlayoutSettingsResponseModel(
int DaysToBuild,
bool SkipMissingItems,
int ScriptedScheduleTimeoutSeconds);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record ResolutionResponseModel(int Id, string Name, int Width, int Height, bool IsCustom);
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
/// <summary>Library scan cadence. <see cref="LibraryRefreshInterval" /> is expressed in hours.</summary>
public record ScannerSettingsResponseModel(int LibraryRefreshInterval);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record UiSettingsResponseModel(bool IsDarkMode, string Language);
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace ErsatzTV.Core.Api.Settings;
/// <summary>
/// Wire-contract mirror of <c>ErsatzTV.Application.Configuration.XmltvBlockBehavior</c>.
/// Duplicated here (rather than referenced directly) because <c>ErsatzTV.Core</c> may not depend on
/// <c>ErsatzTV.Application</c> (see <c>ErsatzTV.Architecture.Tests</c>); the controller mapper translates
/// between the two by value.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<XmltvBlockBehavior>))]
public enum XmltvBlockBehavior
{
SplitTimeEvenly = 0,
UseActualTimes = 1
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record XmltvSettingsResponseModel(
int DaysToBuild,
XmltvTimeZone TimeZone,
XmltvBlockBehavior BlockBehavior);
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace ErsatzTV.Core.Api.Settings;
/// <summary>
/// Wire-contract mirror of <c>ErsatzTV.Application.Configuration.XmltvTimeZone</c>.
/// Duplicated here (rather than referenced directly) because <c>ErsatzTV.Core</c> may not depend on
/// <c>ErsatzTV.Application</c> (see <c>ErsatzTV.Architecture.Tests</c>); the controller mapper translates
/// between the two by value.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<XmltvTimeZone>))]
public enum XmltvTimeZone
{
Local = 0,
Utc = 1
}
@@ -54,6 +54,18 @@ public class GetAllMediaSourcesForApiHandlerTests
result[3].Libraries.Single().ItemCount.ShouldBe(0);
}
[Test]
public async Task Handle_Should_Succeed_On_Empty_Database()
{
// regression: the Dapper item-count query 500'd on a fresh database because
// SQLite reports COUNT(*) as BLOB when there are no rows to infer from
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
result.ShouldBeEmpty();
}
[Test]
public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries()
{
@@ -31,10 +31,12 @@ public class ApiControllerSecurityTests
typeof(LibrariesController),
typeof(MaintenanceController),
typeof(PlayoutController),
typeof(ResolutionController),
typeof(ScannerController),
typeof(ScheduleController),
typeof(ScriptedScheduleController),
typeof(SessionController),
typeof(SettingsController),
typeof(SmartCollectionController)
];
@@ -174,6 +174,25 @@ 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", "401")]
[TestCase("/api/settings/ffmpeg", "put", "422")]
[TestCase("/api/settings/playout", "put", "401")]
[TestCase("/api/settings/playout", "put", "422")]
[TestCase("/api/settings/xmltv", "put", "401")]
[TestCase("/api/settings/xmltv", "put", "422")]
[TestCase("/api/settings/scanner", "put", "401")]
[TestCase("/api/settings/scanner", "put", "422")]
[TestCase("/api/settings/logging", "put", "401")]
[TestCase("/api/settings/logging", "put", "422")]
[TestCase("/api/settings/ui", "put", "401")]
[TestCase("/api/settings/ui", "put", "422")]
[TestCase("/api/settings/hdhr", "put", "401")]
[TestCase("/api/settings/hdhr", "put", "422")]
[TestCase("/api/settings/resolutions", "post", "401")]
[TestCase("/api/settings/resolutions", "post", "422")]
[TestCase("/api/settings/resolutions/{id}", "delete", "401")]
[TestCase("/api/settings/resolutions/{id}", "delete", "404")]
[TestCase("/api/settings/resolutions/{id}", "delete", "422")]
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
string path,
string method,
@@ -0,0 +1,112 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Settings;
using ErsatzTV.Core.Errors;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ResolutionControllerTests
{
private ResolutionController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new ResolutionController(_mediator);
}
[Test]
public async Task GetAll_Should_Map_View_Models_To_Response_Models()
{
_mediator.Send(Arg.Any<GetAllResolutions>(), Arg.Any<CancellationToken>())
.Returns(
new List<ResolutionViewModel>
{
new(1, "1920x1080", 1920, 1080, false),
new(2, "1280x720", 1280, 720, true)
});
List<ResolutionResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(
[
new ResolutionResponseModel(1, "1920x1080", 1920, 1080, false),
new ResolutionResponseModel(2, "1280x720", 1280, 720, true)
]);
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
_mediator.Send(Arg.Any<CreateCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
_mediator.Send(Arg.Any<GetResolutionByName>(), Arg.Any<CancellationToken>())
.Returns(Option<ResolutionViewModel>.Some(new ResolutionViewModel(9, "640x480", 640, 480, true)));
IActionResult result = await _controller.Create(new CreateResolutionRequest(640, 480), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/settings/resolutions/9");
created.Value.ShouldBe(new ResolutionResponseModel(9, "640x480", 640, 480, true));
await _mediator.Received(1).Send(
Arg.Is<CreateCustomResolution>(c => c.Width == 640 && c.Height == 480),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Return_422_When_Resolution_Is_Not_Unique()
{
_mediator.Send(Arg.Any<CreateCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(BaseError.New("Resolution width and height must be unique")));
IActionResult result = await _controller.Create(new CreateResolutionRequest(1920, 1080), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_Unknown_Resolution()
{
_mediator.Send(Arg.Any<DeleteCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(new NotFoundError("Resolution 42 does not exist.")));
IActionResult result = await _controller.Delete(42, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Delete_Should_Return_422_For_NonCustom_Resolution()
{
_mediator.Send(Arg.Any<DeleteCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(BaseError.New("Resolution 3 is not a custom resolution.")));
IActionResult result = await _controller.Delete(3, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
}
@@ -0,0 +1,462 @@
using ErsatzTV.Application.Configuration;
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Application.HDHR;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Settings;
using ErsatzTV.FFmpeg.OutputFormat;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NUnit.Framework;
using Serilog.Events;
using Shouldly;
using static LanguageExt.Prelude;
using ApiXmltvBlockBehavior = ErsatzTV.Core.Api.Settings.XmltvBlockBehavior;
using ApiXmltvTimeZone = ErsatzTV.Core.Api.Settings.XmltvTimeZone;
using Unit = LanguageExt.Unit;
using VmXmltvBlockBehavior = ErsatzTV.Application.Configuration.XmltvBlockBehavior;
using VmXmltvTimeZone = ErsatzTV.Application.Configuration.XmltvTimeZone;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class SettingsControllerTests
{
private SettingsController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new SettingsController(_mediator);
}
[Test]
public async Task GetFfmpeg_Should_Map_Vm_To_Response_Model()
{
var vm = new FFmpegSettingsViewModel
{
FFmpegPath = "/usr/bin/ffmpeg",
FFprobePath = "/usr/bin/ffprobe",
DefaultFFmpegProfileId = 1,
PreferredAudioLanguageCode = "eng",
UseEmbeddedSubtitles = true,
ExtractEmbeddedSubtitles = false,
ProbeForInterlacedFrames = false,
SaveReports = false,
GlobalWatermarkId = 5,
GlobalFallbackFillerId = null,
HlsSegmenterIdleTimeout = 60,
WorkAheadSegmenterLimit = 1,
InitialSegmentCount = 1,
HlsDirectOutputFormat = OutputFormatKind.MpegTs,
DefaultMpegTsScript = "Default"
};
_mediator.Send(Arg.Any<GetFFmpegSettings>(), Arg.Any<CancellationToken>()).Returns(vm);
FFmpegSettingsResponseModel result = await _controller.GetFfmpeg(CancellationToken.None);
result.FFmpegPath.ShouldBe("/usr/bin/ffmpeg");
result.GlobalWatermarkId.ShouldBe(5);
result.GlobalFallbackFillerId.ShouldBeNull();
result.HlsDirectOutputFormat.ShouldBe(OutputFormatKind.MpegTs);
}
[Test]
public async Task UpdateFfmpeg_Should_Map_Request_To_Command_And_Return_Refreshed_Settings()
{
_mediator.Send(Arg.Any<UpdateFFmpegSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
var refreshed = new FFmpegSettingsViewModel
{
FFmpegPath = "/usr/bin/ffmpeg",
FFprobePath = "/usr/bin/ffprobe",
PreferredAudioLanguageCode = "eng",
HlsDirectOutputFormat = OutputFormatKind.Hls,
DefaultMpegTsScript = "Default"
};
_mediator.Send(Arg.Any<GetFFmpegSettings>(), Arg.Any<CancellationToken>()).Returns(refreshed);
IActionResult result = await _controller.UpdateFfmpeg(MakeFfmpegRequest(), CancellationToken.None);
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<FFmpegSettingsResponseModel>().HlsDirectOutputFormat.ShouldBe(OutputFormatKind.Hls);
await _mediator.Received(1).Send(
Arg.Is<UpdateFFmpegSettings>(
c => c.Settings.FFmpegPath == "/usr/bin/ffmpeg" &&
c.Settings.FFprobePath == "/usr/bin/ffprobe" &&
c.Settings.DefaultFFmpegProfileId == 1 &&
c.Settings.PreferredAudioLanguageCode == "eng" &&
c.Settings.UseEmbeddedSubtitles &&
!c.Settings.ExtractEmbeddedSubtitles &&
!c.Settings.ProbeForInterlacedFrames &&
!c.Settings.SaveReports &&
c.Settings.GlobalWatermarkId == null &&
c.Settings.GlobalFallbackFillerId == null &&
c.Settings.HlsSegmenterIdleTimeout == 60 &&
c.Settings.WorkAheadSegmenterLimit == 1 &&
c.Settings.InitialSegmentCount == 1 &&
c.Settings.HlsDirectOutputFormat == OutputFormatKind.MpegTs &&
c.Settings.DefaultMpegTsScript == "Default"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateFfmpeg_Should_Flow_NonNull_Watermark_And_Filler_Ids_Through()
{
_mediator.Send(Arg.Any<UpdateFFmpegSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetFFmpegSettings>(), Arg.Any<CancellationToken>())
.Returns(new FFmpegSettingsViewModel { FFmpegPath = "/usr/bin/ffmpeg", FFprobePath = "/usr/bin/ffprobe" });
UpdateFFmpegSettingsRequest request = MakeFfmpegRequest() with { GlobalWatermarkId = 7, GlobalFallbackFillerId = 9 };
IActionResult result = await _controller.UpdateFfmpeg(request, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdateFFmpegSettings>(
c => c.Settings.GlobalWatermarkId == 7 && c.Settings.GlobalFallbackFillerId == 9),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateFfmpeg_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateFFmpegSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("ffmpeg path does not exist")));
IActionResult result = await _controller.UpdateFfmpeg(MakeFfmpegRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetPlayout_Should_Map_Vm_To_Response_Model()
{
var vm = new PlayoutSettingsViewModel
{
DaysToBuild = 3,
SkipMissingItems = true,
ScriptedScheduleTimeoutSeconds = 45
};
_mediator.Send(Arg.Any<GetPlayoutSettings>(), Arg.Any<CancellationToken>()).Returns(vm);
PlayoutSettingsResponseModel result = await _controller.GetPlayout(CancellationToken.None);
result.ShouldBe(new PlayoutSettingsResponseModel(3, true, 45));
}
[Test]
public async Task UpdatePlayout_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdatePlayoutSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdatePlayout(
new UpdatePlayoutSettingsRequest(0, false, 30),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task UpdatePlayout_Should_Return_Refreshed_Settings_On_Success()
{
_mediator.Send(Arg.Any<UpdatePlayoutSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetPlayoutSettings>(), Arg.Any<CancellationToken>())
.Returns(new PlayoutSettingsViewModel { DaysToBuild = 5, SkipMissingItems = false, ScriptedScheduleTimeoutSeconds = 30 });
IActionResult result = await _controller.UpdatePlayout(
new UpdatePlayoutSettingsRequest(5, false, 30),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new PlayoutSettingsResponseModel(5, false, 30));
await _mediator.Received(1).Send(
Arg.Is<UpdatePlayoutSettings>(
c => c.PlayoutSettings.DaysToBuild == 5 &&
c.PlayoutSettings.SkipMissingItems == false &&
c.PlayoutSettings.ScriptedScheduleTimeoutSeconds == 30),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetXmltv_Should_Map_Vm_Enums_To_Api_Enums()
{
_mediator.Send(Arg.Any<GetXmltvSettings>(), Arg.Any<CancellationToken>())
.Returns(
new XmltvSettingsViewModel
{
DaysToBuild = 2,
TimeZone = VmXmltvTimeZone.Utc,
BlockBehavior = VmXmltvBlockBehavior.UseActualTimes
});
XmltvSettingsResponseModel result = await _controller.GetXmltv(CancellationToken.None);
result.ShouldBe(
new XmltvSettingsResponseModel(
2,
ApiXmltvTimeZone.Utc,
ApiXmltvBlockBehavior.UseActualTimes));
}
[Test]
public async Task UpdateXmltv_Should_Map_Api_Enums_To_Vm_Enums()
{
_mediator.Send(Arg.Any<UpdateXmltvSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetXmltvSettings>(), Arg.Any<CancellationToken>())
.Returns(new XmltvSettingsViewModel { DaysToBuild = 2, TimeZone = VmXmltvTimeZone.Local, BlockBehavior = VmXmltvBlockBehavior.SplitTimeEvenly });
IActionResult result = await _controller.UpdateXmltv(
new UpdateXmltvSettingsRequest(2, ApiXmltvTimeZone.Local, ApiXmltvBlockBehavior.SplitTimeEvenly),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdateXmltvSettings>(
c => c.XmltvSettings.TimeZone == VmXmltvTimeZone.Local &&
c.XmltvSettings.BlockBehavior == VmXmltvBlockBehavior.SplitTimeEvenly),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateXmltv_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateXmltvSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdateXmltv(
new UpdateXmltvSettingsRequest(
2,
ErsatzTV.Core.Api.Settings.XmltvTimeZone.Local,
ErsatzTV.Core.Api.Settings.XmltvBlockBehavior.SplitTimeEvenly),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetScanner_Should_Return_Library_Refresh_Interval()
{
_mediator.Send(Arg.Any<GetLibraryRefreshInterval>(), Arg.Any<CancellationToken>()).Returns(6);
ScannerSettingsResponseModel result = await _controller.GetScanner(CancellationToken.None);
result.ShouldBe(new ScannerSettingsResponseModel(6));
}
[Test]
public async Task UpdateScanner_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateLibraryRefreshInterval>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result =
await _controller.UpdateScanner(new UpdateScannerSettingsRequest(-1), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task UpdateScanner_Should_Return_Refreshed_Settings_On_Success()
{
_mediator.Send(Arg.Any<UpdateLibraryRefreshInterval>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetLibraryRefreshInterval>(), Arg.Any<CancellationToken>()).Returns(12);
IActionResult result =
await _controller.UpdateScanner(new UpdateScannerSettingsRequest(12), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new ScannerSettingsResponseModel(12));
await _mediator.Received(1).Send(
Arg.Is<UpdateLibraryRefreshInterval>(c => c.LibraryRefreshInterval == 12),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogging_Should_Map_Vm_To_Response_Model()
{
var vm = new LoggingSettingsViewModel
{
DefaultMinimumLogLevel = LogEventLevel.Information,
ScanningMinimumLogLevel = LogEventLevel.Debug,
SchedulingMinimumLogLevel = LogEventLevel.Warning,
SearchingMinimumLogLevel = LogEventLevel.Error,
StreamingMinimumLogLevel = LogEventLevel.Verbose,
HttpMinimumLogLevel = LogEventLevel.Fatal
};
_mediator.Send(Arg.Any<GetLoggingSettings>(), Arg.Any<CancellationToken>()).Returns(vm);
LoggingSettingsResponseModel result = await _controller.GetLogging(CancellationToken.None);
result.DefaultMinimumLogLevel.ShouldBe(LogEventLevel.Information);
result.ScanningMinimumLogLevel.ShouldBe(LogEventLevel.Debug);
result.SchedulingMinimumLogLevel.ShouldBe(LogEventLevel.Warning);
result.SearchingMinimumLogLevel.ShouldBe(LogEventLevel.Error);
result.StreamingMinimumLogLevel.ShouldBe(LogEventLevel.Verbose);
result.HttpMinimumLogLevel.ShouldBe(LogEventLevel.Fatal);
}
[Test]
public async Task UpdateLogging_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateLoggingSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdateLogging(MakeLoggingRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task UpdateLogging_Should_Map_Request_To_Command_And_Return_Refreshed_Settings()
{
_mediator.Send(Arg.Any<UpdateLoggingSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
var refreshed = new LoggingSettingsViewModel
{
DefaultMinimumLogLevel = LogEventLevel.Debug,
ScanningMinimumLogLevel = LogEventLevel.Debug,
SchedulingMinimumLogLevel = LogEventLevel.Debug,
SearchingMinimumLogLevel = LogEventLevel.Debug,
StreamingMinimumLogLevel = LogEventLevel.Debug,
HttpMinimumLogLevel = LogEventLevel.Debug
};
_mediator.Send(Arg.Any<GetLoggingSettings>(), Arg.Any<CancellationToken>()).Returns(refreshed);
var request = new UpdateLoggingSettingsRequest(
LogEventLevel.Warning,
LogEventLevel.Error,
LogEventLevel.Fatal,
LogEventLevel.Verbose,
LogEventLevel.Debug,
LogEventLevel.Information);
IActionResult result = await _controller.UpdateLogging(request, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdateLoggingSettings>(
c => c.LoggingSettings.DefaultMinimumLogLevel == LogEventLevel.Warning &&
c.LoggingSettings.ScanningMinimumLogLevel == LogEventLevel.Error &&
c.LoggingSettings.SchedulingMinimumLogLevel == LogEventLevel.Fatal &&
c.LoggingSettings.SearchingMinimumLogLevel == LogEventLevel.Verbose &&
c.LoggingSettings.StreamingMinimumLogLevel == LogEventLevel.Debug &&
c.LoggingSettings.HttpMinimumLogLevel == LogEventLevel.Information),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetUi_Should_Map_Vm_To_Response_Model()
{
_mediator.Send(Arg.Any<GetUiSettings>(), Arg.Any<CancellationToken>())
.Returns(new UiSettingsViewModel { IsDarkMode = true, Language = "en" });
UiSettingsResponseModel result = await _controller.GetUi(CancellationToken.None);
result.ShouldBe(new UiSettingsResponseModel(true, "en"));
}
[Test]
public async Task UpdateUi_Should_Return_Refreshed_Settings_On_Success()
{
_mediator.Send(Arg.Any<UpdateUiSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetUiSettings>(), Arg.Any<CancellationToken>())
.Returns(new UiSettingsViewModel { IsDarkMode = false, Language = "fr" });
IActionResult result =
await _controller.UpdateUi(new UpdateUiSettingsRequest(false, "fr"), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new UiSettingsResponseModel(false, "fr"));
await _mediator.Received(1).Send(
Arg.Is<UpdateUiSettings>(c => c.UiSettings.IsDarkMode == false && c.UiSettings.Language == "fr"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateUi_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateUiSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result =
await _controller.UpdateUi(new UpdateUiSettingsRequest(false, "fr"), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetHdhr_Should_Combine_Tuner_Count_And_Uuid()
{
var uuid = Guid.NewGuid();
_mediator.Send(Arg.Any<GetHDHRTunerCount>(), Arg.Any<CancellationToken>()).Returns(3);
_mediator.Send(Arg.Any<GetHDHRUUID>(), Arg.Any<CancellationToken>()).Returns(uuid);
HdhrSettingsResponseModel result = await _controller.GetHdhr(CancellationToken.None);
result.ShouldBe(new HdhrSettingsResponseModel(3, uuid));
}
[Test]
public async Task UpdateHdhr_Should_Return_Refreshed_Settings_On_Success()
{
var uuid = Guid.NewGuid();
_mediator.Send(Arg.Any<UpdateHDHRTunerCount>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetHDHRTunerCount>(), Arg.Any<CancellationToken>()).Returns(4);
_mediator.Send(Arg.Any<GetHDHRUUID>(), Arg.Any<CancellationToken>()).Returns(uuid);
IActionResult result = await _controller.UpdateHdhr(new UpdateHdhrSettingsRequest(4), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new HdhrSettingsResponseModel(4, uuid));
await _mediator.Received(1).Send(
Arg.Is<UpdateHDHRTunerCount>(c => c.TunerCount == 4),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateHdhr_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateHDHRTunerCount>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("Tuner count must be greater than zero")));
IActionResult result = await _controller.UpdateHdhr(new UpdateHdhrSettingsRequest(0), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
private static UpdateFFmpegSettingsRequest MakeFfmpegRequest() =>
new(
"/usr/bin/ffmpeg",
"/usr/bin/ffprobe",
1,
"eng",
true,
false,
false,
false,
null,
null,
60,
1,
1,
OutputFormatKind.MpegTs,
"Default");
private static UpdateLoggingSettingsRequest MakeLoggingRequest() =>
new(
LogEventLevel.Information,
LogEventLevel.Information,
LogEventLevel.Information,
LogEventLevel.Information,
LogEventLevel.Information,
LogEventLevel.Information);
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.Resolutions;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateResolutionRequest(int Width, int Height)
{
public CreateCustomResolution ToCommand() => new(Width, Height);
}
@@ -0,0 +1,43 @@
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateFFmpegSettingsRequest(
string FFmpegPath,
string FFprobePath,
int DefaultFFmpegProfileId,
string PreferredAudioLanguageCode,
bool UseEmbeddedSubtitles,
bool ExtractEmbeddedSubtitles,
bool ProbeForInterlacedFrames,
bool SaveReports,
int? GlobalWatermarkId,
int? GlobalFallbackFillerId,
int HlsSegmenterIdleTimeout,
int WorkAheadSegmenterLimit,
int InitialSegmentCount,
OutputFormatKind HlsDirectOutputFormat,
string DefaultMpegTsScript)
{
public UpdateFFmpegSettings ToCommand() =>
new(
new FFmpegSettingsViewModel
{
FFmpegPath = FFmpegPath,
FFprobePath = FFprobePath,
DefaultFFmpegProfileId = DefaultFFmpegProfileId,
PreferredAudioLanguageCode = PreferredAudioLanguageCode,
UseEmbeddedSubtitles = UseEmbeddedSubtitles,
ExtractEmbeddedSubtitles = ExtractEmbeddedSubtitles,
ProbeForInterlacedFrames = ProbeForInterlacedFrames,
SaveReports = SaveReports,
GlobalWatermarkId = GlobalWatermarkId,
GlobalFallbackFillerId = GlobalFallbackFillerId,
HlsSegmenterIdleTimeout = HlsSegmenterIdleTimeout,
WorkAheadSegmenterLimit = WorkAheadSegmenterLimit,
InitialSegmentCount = InitialSegmentCount,
HlsDirectOutputFormat = HlsDirectOutputFormat,
DefaultMpegTsScript = DefaultMpegTsScript
});
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.HDHR;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateHdhrSettingsRequest(int TunerCount)
{
public UpdateHDHRTunerCount ToCommand() => new(TunerCount);
}
@@ -0,0 +1,25 @@
using ErsatzTV.Application.Configuration;
using Serilog.Events;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateLoggingSettingsRequest(
LogEventLevel DefaultMinimumLogLevel,
LogEventLevel ScanningMinimumLogLevel,
LogEventLevel SchedulingMinimumLogLevel,
LogEventLevel SearchingMinimumLogLevel,
LogEventLevel StreamingMinimumLogLevel,
LogEventLevel HttpMinimumLogLevel)
{
public UpdateLoggingSettings ToCommand() =>
new(
new LoggingSettingsViewModel
{
DefaultMinimumLogLevel = DefaultMinimumLogLevel,
ScanningMinimumLogLevel = ScanningMinimumLogLevel,
SchedulingMinimumLogLevel = SchedulingMinimumLogLevel,
SearchingMinimumLogLevel = SearchingMinimumLogLevel,
StreamingMinimumLogLevel = StreamingMinimumLogLevel,
HttpMinimumLogLevel = HttpMinimumLogLevel
});
}
@@ -0,0 +1,15 @@
using ErsatzTV.Application.Configuration;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdatePlayoutSettingsRequest(int DaysToBuild, bool SkipMissingItems, int ScriptedScheduleTimeoutSeconds)
{
public UpdatePlayoutSettings ToCommand() =>
new(
new PlayoutSettingsViewModel
{
DaysToBuild = DaysToBuild,
SkipMissingItems = SkipMissingItems,
ScriptedScheduleTimeoutSeconds = ScriptedScheduleTimeoutSeconds
});
}
@@ -0,0 +1,9 @@
using ErsatzTV.Application.Configuration;
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>Library scan cadence. <see cref="LibraryRefreshInterval" /> is expressed in hours.</summary>
public record UpdateScannerSettingsRequest(int LibraryRefreshInterval)
{
public UpdateLibraryRefreshInterval ToCommand() => new(LibraryRefreshInterval);
}
@@ -0,0 +1,14 @@
using ErsatzTV.Application.Configuration;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateUiSettingsRequest(bool IsDarkMode, string Language)
{
public UpdateUiSettings ToCommand() =>
new(
new UiSettingsViewModel
{
IsDarkMode = IsDarkMode,
Language = Language
});
}
@@ -0,0 +1,33 @@
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 = ToVmTimeZone(TimeZone),
BlockBehavior = ToVmBlockBehavior(BlockBehavior)
});
private static XmltvTimeZone ToVmTimeZone(ApiXmltvTimeZone timeZone) =>
timeZone switch
{
ApiXmltvTimeZone.Local => XmltvTimeZone.Local,
ApiXmltvTimeZone.Utc => XmltvTimeZone.Utc,
_ => throw new ArgumentOutOfRangeException(nameof(timeZone), timeZone, null)
};
private static XmltvBlockBehavior ToVmBlockBehavior(ApiXmltvBlockBehavior blockBehavior) =>
blockBehavior switch
{
ApiXmltvBlockBehavior.SplitTimeEvenly => XmltvBlockBehavior.SplitTimeEvenly,
ApiXmltvBlockBehavior.UseActualTimes => XmltvBlockBehavior.UseActualTimes,
_ => throw new ArgumentOutOfRangeException(nameof(blockBehavior), blockBehavior, null)
};
}
@@ -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,60 @@ public class ResolutionController(IMediator mediator) : ControllerBase
Option<ResolutionViewModel> result = await mediator.Send(new GetResolutionByName(name), cancellationToken);
return result.Match<ActionResult<ResolutionViewModel>>(i => Ok(i), () => NotFound());
}
[HttpGet("/api/settings/resolutions", Name = "GetResolutions")]
[Tags("Settings")]
[EndpointSummary("Get all resolutions, including custom resolutions")]
[ProducesResponseType(typeof(List<ResolutionResponseModel>), StatusCodes.Status200OK)]
public async Task<List<ResolutionResponseModel>> GetAll(CancellationToken cancellationToken)
{
List<ResolutionViewModel> resolutions = await mediator.Send(new GetAllResolutions(), cancellationToken);
return resolutions.Map(ProjectToResponseModel).ToList();
}
[HttpPost("/api/settings/resolutions", Name = "CreateResolution")]
[Tags("Settings")]
[EndpointSummary("Create a custom resolution")]
[ProducesResponseType(typeof(ResolutionResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody]
CreateResolutionRequest request,
CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Some: error => Task.FromResult(error.ToErrorResult()),
None: async () =>
{
string name = $"{request.Width}x{request.Height}";
Option<ResolutionViewModel> resolution =
await mediator.Send(new GetResolutionByName(name), cancellationToken);
return resolution.Match(
Some: vm => (IActionResult)new CreatedResult(
$"/api/settings/resolutions/{vm.Id}",
ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpDelete("/api/settings/resolutions/{id:int}", Name = "DeleteResolution")]
[Tags("Settings")]
[EndpointSummary("Delete a custom resolution")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeleteCustomResolution(id), cancellationToken);
return result.Match<IActionResult>(
Some: error => error.ToErrorResult(),
None: () => new NoContentResult());
}
private static ResolutionResponseModel ProjectToResponseModel(ResolutionViewModel vm) =>
new(vm.Id, vm.Name, vm.Width, vm.Height, vm.IsCustom);
}
@@ -0,0 +1,305 @@
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;
using VmXmltvBlockBehavior = ErsatzTV.Application.Configuration.XmltvBlockBehavior;
using VmXmltvTimeZone = ErsatzTV.Application.Configuration.XmltvTimeZone;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class SettingsController(IMediator mediator) : ControllerBase
{
// FFmpeg settings
[HttpGet("/api/settings/ffmpeg", Name = "GetFfmpegSettings")]
[Tags("Settings")]
[EndpointSummary("Get global FFmpeg settings")]
[ProducesResponseType(typeof(FFmpegSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<FFmpegSettingsResponseModel> GetFfmpeg(CancellationToken cancellationToken)
{
FFmpegSettingsViewModel settings = await mediator.Send(new GetFFmpegSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/ffmpeg", Name = "UpdateFfmpegSettings")]
[Tags("Settings")]
[EndpointSummary("Update global FFmpeg settings")]
[ProducesResponseType(typeof(FFmpegSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateFfmpeg(
[Required] [FromBody]
UpdateFFmpegSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
FFmpegSettingsViewModel settings = await mediator.Send(new GetFFmpegSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// Playout settings
[HttpGet("/api/settings/playout", Name = "GetPlayoutSettings")]
[Tags("Settings")]
[EndpointSummary("Get global playout settings")]
[ProducesResponseType(typeof(PlayoutSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<PlayoutSettingsResponseModel> GetPlayout(CancellationToken cancellationToken)
{
PlayoutSettingsViewModel settings = await mediator.Send(new GetPlayoutSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/playout", Name = "UpdatePlayoutSettings")]
[Tags("Settings")]
[EndpointSummary("Update global playout settings")]
[ProducesResponseType(typeof(PlayoutSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdatePlayout(
[Required] [FromBody]
UpdatePlayoutSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
PlayoutSettingsViewModel settings = await mediator.Send(new GetPlayoutSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// XMLTV settings
[HttpGet("/api/settings/xmltv", Name = "GetXmltvSettings")]
[Tags("Settings")]
[EndpointSummary("Get global XMLTV settings")]
[ProducesResponseType(typeof(XmltvSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<XmltvSettingsResponseModel> GetXmltv(CancellationToken cancellationToken)
{
XmltvSettingsViewModel settings = await mediator.Send(new GetXmltvSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/xmltv", Name = "UpdateXmltvSettings")]
[Tags("Settings")]
[EndpointSummary("Update global XMLTV settings")]
[ProducesResponseType(typeof(XmltvSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateXmltv(
[Required] [FromBody]
UpdateXmltvSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
XmltvSettingsViewModel settings = await mediator.Send(new GetXmltvSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// Scanner settings
[HttpGet("/api/settings/scanner", Name = "GetScannerSettings")]
[Tags("Settings")]
[EndpointSummary("Get library scan cadence")]
[ProducesResponseType(typeof(ScannerSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<ScannerSettingsResponseModel> GetScanner(CancellationToken cancellationToken)
{
int libraryRefreshInterval = await mediator.Send(new GetLibraryRefreshInterval(), cancellationToken);
return new ScannerSettingsResponseModel(libraryRefreshInterval);
}
[HttpPut("/api/settings/scanner", Name = "UpdateScannerSettings")]
[Tags("Settings")]
[EndpointSummary("Update library scan cadence")]
[ProducesResponseType(typeof(ScannerSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateScanner(
[Required] [FromBody]
UpdateScannerSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
int libraryRefreshInterval = await mediator.Send(new GetLibraryRefreshInterval(), cancellationToken);
return (IActionResult)new OkObjectResult(new ScannerSettingsResponseModel(libraryRefreshInterval));
});
}
// Logging settings
[HttpGet("/api/settings/logging", Name = "GetLoggingSettings")]
[Tags("Settings")]
[EndpointSummary("Get per-area minimum log levels")]
[ProducesResponseType(typeof(LoggingSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<LoggingSettingsResponseModel> GetLogging(CancellationToken cancellationToken)
{
LoggingSettingsViewModel settings = await mediator.Send(new GetLoggingSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/logging", Name = "UpdateLoggingSettings")]
[Tags("Settings")]
[EndpointSummary("Update per-area minimum log levels")]
[ProducesResponseType(typeof(LoggingSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateLogging(
[Required] [FromBody]
UpdateLoggingSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
LoggingSettingsViewModel settings = await mediator.Send(new GetLoggingSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// UI settings
[HttpGet("/api/settings/ui", Name = "GetUiSettings")]
[Tags("Settings")]
[EndpointSummary("Get UI preferences")]
[ProducesResponseType(typeof(UiSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<UiSettingsResponseModel> GetUi(CancellationToken cancellationToken)
{
UiSettingsViewModel settings = await mediator.Send(new GetUiSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/ui", Name = "UpdateUiSettings")]
[Tags("Settings")]
[EndpointSummary("Update UI preferences")]
[ProducesResponseType(typeof(UiSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateUi(
[Required] [FromBody]
UpdateUiSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
UiSettingsViewModel settings = await mediator.Send(new GetUiSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// HDHR settings
[HttpGet("/api/settings/hdhr", Name = "GetHdhrSettings")]
[Tags("Settings")]
[EndpointSummary("Get HDHomeRun emulation settings")]
[ProducesResponseType(typeof(HdhrSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<HdhrSettingsResponseModel> GetHdhr(CancellationToken cancellationToken) =>
await LoadHdhrSettings(cancellationToken);
[HttpPut("/api/settings/hdhr", Name = "UpdateHdhrSettings")]
[Tags("Settings")]
[EndpointSummary("Update HDHomeRun emulation settings")]
[ProducesResponseType(typeof(HdhrSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateHdhr(
[Required] [FromBody]
UpdateHdhrSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ => (IActionResult)new OkObjectResult(await LoadHdhrSettings(cancellationToken)));
}
private async Task<HdhrSettingsResponseModel> LoadHdhrSettings(CancellationToken cancellationToken)
{
int tunerCount = await mediator.Send(new GetHDHRTunerCount(), cancellationToken);
Guid uuid = await mediator.Send(new GetHDHRUUID(), cancellationToken);
return new HdhrSettingsResponseModel(tunerCount, uuid);
}
private static FFmpegSettingsResponseModel ProjectToResponseModel(FFmpegSettingsViewModel vm) =>
new(
vm.FFmpegPath,
vm.FFprobePath,
vm.DefaultFFmpegProfileId,
vm.PreferredAudioLanguageCode,
vm.UseEmbeddedSubtitles,
vm.ExtractEmbeddedSubtitles,
vm.ProbeForInterlacedFrames,
vm.SaveReports,
vm.GlobalWatermarkId,
vm.GlobalFallbackFillerId,
vm.HlsSegmenterIdleTimeout,
vm.WorkAheadSegmenterLimit,
vm.InitialSegmentCount,
vm.HlsDirectOutputFormat,
vm.DefaultMpegTsScript);
private static PlayoutSettingsResponseModel ProjectToResponseModel(PlayoutSettingsViewModel vm) =>
new(vm.DaysToBuild, vm.SkipMissingItems, vm.ScriptedScheduleTimeoutSeconds);
private static XmltvSettingsResponseModel ProjectToResponseModel(XmltvSettingsViewModel vm) =>
new(vm.DaysToBuild, 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);
}
+6
View File
@@ -127,8 +127,14 @@ public class Startup
return;
}
// Core's own enums get scanned wholesale. A couple of API response DTOs (settings endpoints)
// also expose enums from lower layers Core is allowed to depend on (ErsatzTV.FFmpeg) or from
// Serilog; list those individually instead of Assembly.GetTypes()-scanning their assemblies,
// since eagerly loading every type in ErsatzTV.FFmpeg (e.g. NvEncSharp-backed types) can throw
// a ReflectionTypeLoadException in environments missing optional native hardware-encoder deps.
Dictionary<string, Type> enumTypes = typeof(Core.Domain.PlayoutMode).Assembly.GetTypes()
.Where(type => type.IsEnum)
.Concat([typeof(FFmpeg.OutputFormat.OutputFormatKind), typeof(Serilog.Events.LogEventLevel)])
.GroupBy(type => type.Name)
.ToDictionary(group => group.Key, group => group.First());
File diff suppressed because it is too large Load Diff
@@ -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.
@@ -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 (
<div style={{ maxWidth: 760, display: "flex", flexDirection: "column", gap: 16, animation: "ctv-fade-in 240ms cubic-bezier(0.16,1,0.3,1)" }}>
<div>
<div style={{ font: "var(--weight-semibold) var(--text-lg)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>
{subtitle && <div style={{ marginTop: 5, font: "var(--text-sm)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</div>}
</div>
{children}
</div>
);
}
// One setting: label + help left, control right. Flush rows inside Card padded={false}.
function Row({ label, help, children, first, control = 260 }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 20, padding: "13px 16px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-disabled)" }}>{help}</div>}
</div>
<div style={{ flex: `0 0 ${control}px`, display: "flex", justifyContent: "flex-end" }}>{children}</div>
</div>
);
}
function LegacyCallout({ children }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px dashed var(--border-control)" }}>
<Ico n="ExternalLink" s={14} color="var(--text-disabled)" />
<span style={{ flex: 1, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>{children}</span>
<Badge tone="neutral">Legacy UI</Badge>
</div>
);
}
function PathInput({ value, onChange }) {
const ok = value.startsWith("/");
return (
<Input size="sm" fullWidth value={value} onChange={(e) => onChange(e.target.value)}
style={mono}
trailing={ok
? <Ico n="CircleCheck" s={14} color="var(--status-ok)" />
: <Tooltip label="File not found"><Ico n="CircleAlert" s={14} color="var(--status-error)" /></Tooltip>} />
);
}
function NumInput({ value, onChange, unit, w = 120 }) {
return (
<Input size="sm" type="number" value={value} onChange={(e) => onChange(e.target.value)}
style={{ width: w, ...mono }}
trailing={unit && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{unit}</span>} />
);
}
/* ---------- panes ---------- */
function GeneralPane({ v, set }) {
return (
<Pane title="General" subtitle="Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.">
<Card padded={false}>
<Row first label="Theme" help="Legacy UI color scheme.">
<Select size="sm" fullWidth value={v.theme} onChange={(e) => set("theme", e.target.value)} options={["Dark", "Light"]} />
</Row>
<Row label="Language" help="Display language for the legacy UI.">
<Select size="sm" fullWidth value={v.language} onChange={(e) => set("language", e.target.value)}
options={["English (en-US)", "English (en-GB)", "Deutsch (de)", "Español (es)", "Français (fr)", "Nederlands (nl)"]} />
</Row>
</Card>
</Pane>
);
}
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 (
<Pane title="Streaming" subtitle="FFmpeg engine, transcoding defaults and HLS session tuning.">
<Card title="FFmpeg" padded={false}>
<Row first label="FFmpeg path" help="Binary used for all transcoding." control={320}>
<PathInput value={v.ffmpegPath} onChange={(x) => set("ffmpegPath", x)} />
</Row>
<Row label="FFprobe path" help="Binary used to inspect media files." control={320}>
<PathInput value={v.ffprobePath} onChange={(x) => set("ffprobePath", x)} />
</Row>
<Row label="Default profile" help="Transcoding profile for new channels.">
<Select size="sm" fullWidth value={v.defaultProfile} onChange={(e) => set("defaultProfile", e.target.value)} options={PROFILES} />
</Row>
<Row label="Preferred audio language" help="Track picked when media has multiple audio languages.">
<Select size="sm" fullWidth value={v.preferredAudioLanguage} onChange={(e) => set("preferredAudioLanguage", e.target.value)}
options={["English (eng)", "Japanese (jpn)", "Spanish (spa)", "French (fra)", "German (deu)"]} />
</Row>
<Row label="Use embedded subtitles" help="Burn in / pass through subtitles found in media files.">
<Switch size="sm" checked={v.useEmbeddedSubtitles} onChange={(x) => set("useEmbeddedSubtitles", x)} />
</Row>
<Row label="Extract embedded subtitles" help="Copy text subtitles out ahead of playback (slower scans).">
<Switch size="sm" checked={v.extractEmbeddedSubtitles} onChange={(x) => set("extractEmbeddedSubtitles", x)} />
</Row>
<Row label="Probe for interlaced frames" help="Deeper scan to auto-apply deinterlacing.">
<Switch size="sm" checked={v.probeInterlaced} onChange={(x) => set("probeInterlaced", x)} />
</Row>
<Row label="Save troubleshooting reports" help="Write a report per transcode session to the config folder.">
<Switch size="sm" checked={v.saveReports} onChange={(x) => set("saveReports", x)} />
</Row>
</Card>
<Card title="Global defaults" padded={false}>
<Row first label="Watermark" help="Applied to every channel without its own watermark.">
<Select size="sm" fullWidth value={v.globalWatermark} onChange={(e) => set("globalWatermark", e.target.value)}
options={["(none)", "Station bug — corner", "Station bug — large"]} />
</Row>
<Row label="Fallback filler" help="Plays when a playout has nothing scheduled.">
<Select size="sm" fullWidth value={v.globalFallbackFiller} onChange={(e) => set("globalFallbackFiller", e.target.value)}
options={["(none)", "Retro Bumpers", "Ad Break Pool", "Test Pattern"]} />
</Row>
</Card>
<Card title="HLS sessions" padded={false}>
<Row first label="Idle timeout" help="Stop a segmenter after no client has asked for segments.">
<NumInput value={v.hlsIdleTimeout} onChange={(x) => set("hlsIdleTimeout", x)} unit="sec" />
</Row>
<Row label="Work-ahead limit" help="Max segmenters transcoding ahead of live at once.">
<NumInput value={v.hlsWorkAhead} onChange={(x) => set("hlsWorkAhead", x)} unit="sessions" />
</Row>
<Row label="Initial segments" help="Segments ready before playback starts.">
<NumInput value={v.hlsInitialSegments} onChange={(x) => set("hlsInitialSegments", x)} unit="segments" />
</Row>
<Row label="HLS Direct output format" help="Container for HLS Direct channels.">
<Select size="sm" fullWidth value={v.hlsDirectFormat} onChange={(e) => set("hlsDirectFormat", e.target.value)} options={["MPEG-TS", "MP4", "MKV"]} />
</Row>
<Row label="Default MPEG-TS script" help="Channels in MPEG-TS mode use this script unless overridden.">
<Select size="sm" fullWidth value={v.mpegTsScript} onChange={(e) => set("mpegTsScript", e.target.value)} options={["(default template)", "custom-remux.sh"]} />
</Row>
</Card>
<Card title="Custom resolutions" subtitle="Extra target resolutions selectable in FFmpeg profiles" padded={false}>
{customRes.map((r, i) => (
<div key={r.id} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<Ico n="Proportions" s={15} color="var(--text-disabled)" />
<span style={{ flex: 1, ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>{r.w} × {r.h}</span>
<Tag>custom</Tag>
<IconButton size="sm" variant="ghost" title="Delete resolution" onClick={() => setCustomRes(customRes.filter((x) => x.id !== r.id))}>
<Ico n="Trash2" s={14} />
</IconButton>
</div>
))}
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 16px", borderTop: customRes.length ? "1px solid var(--border-hairline)" : "none", background: "var(--ctv-bg-sunken)" }}>
<Input size="sm" type="number" placeholder="width" value={resW} onChange={(e) => setResW(e.target.value)} style={{ width: 96, ...mono }} />
<span style={{ color: "var(--text-disabled)" }}>×</span>
<Input size="sm" type="number" placeholder="height" value={resH} onChange={(e) => setResH(e.target.value)} style={{ width: 96, ...mono }} />
<span style={{ flex: 1 }} />
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={13} />} disabled={!resW || !resH}
onClick={() => { setCustomRes([...customRes, { id: Date.now(), w: +resW, h: +resH }]); setResW(""); setResH(""); }}>
Add resolution
</Button>
</div>
</Card>
<Card title="FFmpeg profiles" subtitle="Fine-grained transcoding presets" padded={false}
actions={<Badge tone="neutral">{PROFILES.length} profiles</Badge>}>
{PROFILES.map((p, i) => (
<div key={p} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<Ico n="Cpu" s={15} color="var(--ctv-accent)" />
<span style={{ flex: 1, font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{p}</span>
{p === v.defaultProfile && <Badge tone="accent">Default</Badge>}
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{i + 1} ch</span>
</div>
))}
<div style={{ padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
<LegacyCallout>Profile editing (hardware acceleration, bitrates, tonemapping) stays in the legacy UI for now.</LegacyCallout>
</div>
</Card>
</Pane>
);
}
function PlayoutPane({ v, set }) {
return (
<Pane title="Playout" subtitle="Defaults for how far ahead playouts are built and how gaps are handled.">
<Card padded={false}>
<Row first label="Days to build" help="How many days of programming each playout keeps ahead.">
<NumInput value={v.playoutDays} onChange={(x) => set("playoutDays", x)} unit="days" />
</Row>
<Row label="Skip missing items" help="Skip items whose files are missing instead of failing the build.">
<Switch size="sm" checked={v.skipMissing} onChange={(x) => set("skipMissing", x)} />
</Row>
<Row label="Scripted schedule timeout" help="Max run time for external scripted-schedule processes.">
<NumInput value={v.scriptedTimeout} onChange={(x) => set("scriptedTimeout", x)} unit="sec" />
</Row>
</Card>
</Pane>
);
}
function XmltvPane({ v, set }) {
return (
<Pane title="Guide (XMLTV)" subtitle="Shape of the XMLTV guide data served to Jellyfin and other clients.">
<Card padded={false}>
<Row first label="Days to build" help="Guide horizon exported per channel.">
<NumInput value={v.xmltvDays} onChange={(x) => set("xmltvDays", x)} unit="days" />
</Row>
<Row label="Time zone" help="Timestamps written into the XMLTV file.">
<Select size="sm" fullWidth value={v.xmltvTimeZone} onChange={(e) => set("xmltvTimeZone", e.target.value)} options={["Local", "UTC"]} />
</Row>
<Row label="Block behavior" help="How block-schedule playouts are represented in the guide.">
<Select size="sm" fullWidth value={v.xmltvBlockBehavior} onChange={(e) => set("xmltvBlockBehavior", e.target.value)} options={["Split time evenly", "Use actual times"]} />
</Row>
</Card>
</Pane>
);
}
function ScannerPane({ v, set }) {
return (
<Pane title="Scanner" subtitle="Background scanning of local libraries.">
<Card padded={false}>
<Row first label="Library refresh interval" help="Hours between automatic local-library scans. 0 disables automatic scanning.">
<NumInput value={v.refreshHours} onChange={(x) => set("refreshHours", x)} unit="hours" />
</Row>
</Card>
{v.refreshHours === "0" && (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--status-warn-soft)", border: "1px solid transparent" }}>
<Ico n="TriangleAlert" s={14} color="var(--status-warn)" />
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
Automatic scanning is disabled — libraries only update when scanned manually.
</span>
</div>
)}
</Pane>
);
}
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 (
<Pane title="Logging" subtitle="Minimum level written per category. Verbose and Debug are noisy — use for troubleshooting only.">
<Card padded={false}>
{CATS.map((c, i) => (
<Row key={c.key} first={i === 0} label={c.label} help={c.help} control={180}>
<Select size="sm" fullWidth value={v[c.key]} onChange={(e) => set(c.key, e.target.value)} options={LOG_LEVELS} />
</Row>
))}
</Card>
</Pane>
);
}
function SystemPane({ v, set }) {
const UUID = "6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41";
return (
<Pane title="System" subtitle="HDHomeRun emulation, connected media sources and server info.">
<Card title="HDHomeRun" padded={false}>
<Row first label="Tuner count" help="Concurrent streams advertised to clients that speak HDHR.">
<NumInput value={v.tunerCount} onChange={(x) => set("tunerCount", x)} unit="tuners" />
</Row>
<Row label="Device UUID" help="Identity presented to HDHR clients." control={340}>
<div style={{ display: "flex", alignItems: "center", gap: 6, width: "100%", justifyContent: "flex-end" }}>
<span style={{ ...mono, font: "var(--text-xs)/1 var(--font-mono)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis" }}>{UUID}</span>
<IconButton size="sm" variant="ghost" title="Copy UUID"><Ico n="Copy" s={13} /></IconButton>
</div>
</Row>
</Card>
<Card title="Media sources" subtitle="Read-only — add or edit connections in the legacy UI" padded={false}>
{D.sources.map((s, i) => (
<div key={s.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<Ico n={s.kind === "Local" ? "HardDrive" : "Cast"} s={15} color="var(--text-secondary)" />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{s.name}</span>
<span style={{ marginLeft: 8, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{s.detail}</span>
</div>
<StatusDot status={s.status === "connected" ? "ok" : "warn"} size={7} />
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", width: 62, textAlign: "right" }}>{s.lastScan}</span>
</div>
))}
<div style={{ padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
<LegacyCallout>Connections, credentials and library sync are managed in the legacy UI.</LegacyCallout>
</div>
</Card>
<Card title="About" padded={false}>
<Row first label="Version" control={220}>
<span style={{ ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>v26.4.0</span>
</Row>
<Row label="Health checks" help="14 checks — 2 warnings." control={220}>
<Button size="sm" variant="secondary" endIcon={<Ico n="ArrowRight" s={13} />}>Open Dashboard</Button>
</Row>
</Card>
</Pane>
);
}
/* ---------- screen ---------- */
function Settings() {
const [section, setSection] = React.useState("general");
const [saved, setSaved] = React.useState(DEFAULTS);
const [draft, setDraft] = React.useState(DEFAULTS);
const [justSaved, setJustSaved] = React.useState(false);
const set = (k, val) => setDraft((d) => ({ ...d, [k]: val }));
const dirty = Object.keys(DEFAULTS).filter((k) => draft[k] !== saved[k]);
const PANES = {
general: GeneralPane, streaming: StreamingPane, playout: PlayoutPane,
xmltv: XmltvPane, scanner: ScannerPane, logging: LoggingPane, system: SystemPane,
};
const PaneCmp = PANES[section];
return (
<div style={{ display: "flex", height: "100%", minHeight: 0 }}>
{/* sub-nav rail */}
<div style={{ width: 224, flex: "0 0 auto", borderRight: "1px solid var(--border-hairline)", overflow: "auto", padding: "12px 8px" }}>
{SECTIONS.map((s) => {
const active = s.id === section;
return (
<button key={s.id} type="button" onClick={() => setSection(s.id)} className="ctv-press"
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "9px 10px", marginBottom: 2, border: "none", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left",
background: active ? "var(--ctv-accent-soft)" : "transparent" }}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--ctv-surface-2)"; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}>
<Ico n={s.icon} s={16} color={active ? "var(--ctv-accent)" : "var(--text-secondary)"} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: `var(--weight-medium) var(--text-sm)/1 var(--font-sans)`, color: active ? "var(--text-primary)" : "var(--text-secondary)" }}>{s.label}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.hint}</div>
</div>
</button>
);
})}
</div>
{/* pane */}
<div style={{ flex: 1, minWidth: 0, minHeight: 0, position: "relative", display: "flex", flexDirection: "column" }}>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "20px 24px 96px" }}>
<PaneCmp key={section} v={draft} set={set} />
</div>
{/* floating save bar */}
{(dirty.length > 0 || justSaved) && (
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, display: "flex", justifyContent: "center", padding: "0 24px 18px", pointerEvents: "none" }}>
<div className="ctv-lift" style={{ pointerEvents: "auto", display: "flex", alignItems: "center", gap: 14, padding: "10px 12px 10px 16px", borderRadius: "var(--radius-pill)", background: "var(--surface-raised)", border: "1px solid var(--border-control)", boxShadow: "var(--shadow-pop)", animation: "ctv-fade-in 200ms cubic-bezier(0.16,1,0.3,1)" }}>
{justSaved ? (
<>
<Ico n="CircleCheck" s={15} color="var(--status-ok)" />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Settings saved</span>
</>
) : (
<>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
{dirty.length} unsaved {dirty.length === 1 ? "change" : "changes"}
</span>
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
<Button size="sm" variant="ghost" onClick={() => setDraft(saved)}>Discard</Button>
<Button size="sm" variant="primary" startIcon={<Ico n="Check" s={14} />}
onClick={() => { setSaved(draft); setJustSaved(true); setTimeout(() => setJustSaved(false), 1800); }}>
Save changes
</Button>
</>
)}
</div>
</div>
)}
</div>
</div>
);
}
window.CTVSettings = Settings;
})();
@@ -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 (
<div style={{ maxWidth: 760, display: "flex", flexDirection: "column", gap: 16, animation: "ctv-fade-in 240ms cubic-bezier(0.16,1,0.3,1)" }}>
<div>
<div style={{ font: "var(--weight-semibold) var(--text-lg)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>
{subtitle && <div style={{ marginTop: 5, font: "var(--text-sm)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</div>}
</div>
{children}
</div>
);
}
// One setting: label + help left, control right. Flush rows inside Card padded={false}.
function Row({ label, help, children, first, control = 260 }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 20, padding: "13px 16px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-disabled)" }}>{help}</div>}
</div>
<div style={{ flex: `0 0 ${control}px`, display: "flex", justifyContent: "flex-end" }}>{children}</div>
</div>
);
}
function LegacyCallout({ children }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px dashed var(--border-control)" }}>
<Ico n="ExternalLink" s={14} color="var(--text-disabled)" />
<span style={{ flex: 1, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>{children}</span>
<Badge tone="neutral">Legacy UI</Badge>
</div>
);
}
function PathInput({ value, onChange }) {
const ok = value.startsWith("/");
return (
<Input size="sm" fullWidth value={value} onChange={(e) => onChange(e.target.value)}
style={mono}
trailing={ok
? <Ico n="CircleCheck" s={14} color="var(--status-ok)" />
: <Tooltip label="File not found"><Ico n="CircleAlert" s={14} color="var(--status-error)" /></Tooltip>} />
);
}
function NumInput({ value, onChange, unit, w = 120 }) {
return (
<Input size="sm" type="number" value={value} onChange={(e) => onChange(e.target.value)}
style={{ width: w, ...mono }}
trailing={unit && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{unit}</span>} />
);
}
/* ---------- panes ---------- */
function GeneralPane({ v, set }) {
return (
<Pane title="General" subtitle="Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.">
<Card padded={false}>
<Row first label="Theme" help="Legacy UI color scheme.">
<Select size="sm" fullWidth value={v.theme} onChange={(e) => set("theme", e.target.value)} options={["Dark", "Light"]} />
</Row>
<Row label="Language" help="Display language for the legacy UI.">
<Select size="sm" fullWidth value={v.language} onChange={(e) => set("language", e.target.value)}
options={["English (en-US)", "English (en-GB)", "Deutsch (de)", "Español (es)", "Français (fr)", "Nederlands (nl)"]} />
</Row>
</Card>
</Pane>
);
}
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 (
<Pane title="Streaming" subtitle="FFmpeg engine, transcoding defaults and HLS session tuning.">
<Card title="FFmpeg" padded={false}>
<Row first label="FFmpeg path" help="Binary used for all transcoding." control={320}>
<PathInput value={v.ffmpegPath} onChange={(x) => set("ffmpegPath", x)} />
</Row>
<Row label="FFprobe path" help="Binary used to inspect media files." control={320}>
<PathInput value={v.ffprobePath} onChange={(x) => set("ffprobePath", x)} />
</Row>
<Row label="Default profile" help="Transcoding profile for new channels.">
<Select size="sm" fullWidth value={v.defaultProfile} onChange={(e) => set("defaultProfile", e.target.value)} options={PROFILES} />
</Row>
<Row label="Preferred audio language" help="Track picked when media has multiple audio languages.">
<Select size="sm" fullWidth value={v.preferredAudioLanguage} onChange={(e) => set("preferredAudioLanguage", e.target.value)}
options={["English (eng)", "Japanese (jpn)", "Spanish (spa)", "French (fra)", "German (deu)"]} />
</Row>
<Row label="Use embedded subtitles" help="Burn in / pass through subtitles found in media files.">
<Switch size="sm" checked={v.useEmbeddedSubtitles} onChange={(x) => set("useEmbeddedSubtitles", x)} />
</Row>
<Row label="Extract embedded subtitles" help="Copy text subtitles out ahead of playback (slower scans).">
<Switch size="sm" checked={v.extractEmbeddedSubtitles} onChange={(x) => set("extractEmbeddedSubtitles", x)} />
</Row>
<Row label="Probe for interlaced frames" help="Deeper scan to auto-apply deinterlacing.">
<Switch size="sm" checked={v.probeInterlaced} onChange={(x) => set("probeInterlaced", x)} />
</Row>
<Row label="Save troubleshooting reports" help="Write a report per transcode session to the config folder.">
<Switch size="sm" checked={v.saveReports} onChange={(x) => set("saveReports", x)} />
</Row>
</Card>
<Card title="Global defaults" padded={false}>
<Row first label="Watermark" help="Applied to every channel without its own watermark.">
<Select size="sm" fullWidth value={v.globalWatermark} onChange={(e) => set("globalWatermark", e.target.value)}
options={["(none)", "Station bug — corner", "Station bug — large"]} />
</Row>
<Row label="Fallback filler" help="Plays when a playout has nothing scheduled.">
<Select size="sm" fullWidth value={v.globalFallbackFiller} onChange={(e) => set("globalFallbackFiller", e.target.value)}
options={["(none)", "Retro Bumpers", "Ad Break Pool", "Test Pattern"]} />
</Row>
</Card>
<Card title="HLS sessions" padded={false}>
<Row first label="Idle timeout" help="Stop a segmenter after no client has asked for segments.">
<NumInput value={v.hlsIdleTimeout} onChange={(x) => set("hlsIdleTimeout", x)} unit="sec" />
</Row>
<Row label="Work-ahead limit" help="Max segmenters transcoding ahead of live at once.">
<NumInput value={v.hlsWorkAhead} onChange={(x) => set("hlsWorkAhead", x)} unit="sessions" />
</Row>
<Row label="Initial segments" help="Segments ready before playback starts.">
<NumInput value={v.hlsInitialSegments} onChange={(x) => set("hlsInitialSegments", x)} unit="segments" />
</Row>
<Row label="HLS Direct output format" help="Container for HLS Direct channels.">
<Select size="sm" fullWidth value={v.hlsDirectFormat} onChange={(e) => set("hlsDirectFormat", e.target.value)} options={["MPEG-TS", "MP4", "MKV"]} />
</Row>
<Row label="Default MPEG-TS script" help="Channels in MPEG-TS mode use this script unless overridden.">
<Select size="sm" fullWidth value={v.mpegTsScript} onChange={(e) => set("mpegTsScript", e.target.value)} options={["(default template)", "custom-remux.sh"]} />
</Row>
</Card>
<Card title="Custom resolutions" subtitle="Extra target resolutions selectable in FFmpeg profiles" padded={false}>
{customRes.map((r, i) => (
<div key={r.id} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<Ico n="Proportions" s={15} color="var(--text-disabled)" />
<span style={{ flex: 1, ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>{r.w} × {r.h}</span>
<Tag>custom</Tag>
<IconButton size="sm" variant="ghost" title="Delete resolution" onClick={() => setCustomRes(customRes.filter((x) => x.id !== r.id))}>
<Ico n="Trash2" s={14} />
</IconButton>
</div>
))}
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 16px", borderTop: customRes.length ? "1px solid var(--border-hairline)" : "none", background: "var(--ctv-bg-sunken)" }}>
<Input size="sm" type="number" placeholder="width" value={resW} onChange={(e) => setResW(e.target.value)} style={{ width: 96, ...mono }} />
<span style={{ color: "var(--text-disabled)" }}>×</span>
<Input size="sm" type="number" placeholder="height" value={resH} onChange={(e) => setResH(e.target.value)} style={{ width: 96, ...mono }} />
<span style={{ flex: 1 }} />
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={13} />} disabled={!resW || !resH}
onClick={() => { setCustomRes([...customRes, { id: Date.now(), w: +resW, h: +resH }]); setResW(""); setResH(""); }}>
Add resolution
</Button>
</div>
</Card>
<Card title="FFmpeg profiles" subtitle="Fine-grained transcoding presets" padded={false}
actions={<Badge tone="neutral">{PROFILES.length} profiles</Badge>}>
{PROFILES.map((p, i) => (
<div key={p} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<Ico n="Cpu" s={15} color="var(--ctv-accent)" />
<span style={{ flex: 1, font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{p}</span>
{p === v.defaultProfile && <Badge tone="accent">Default</Badge>}
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{i + 1} ch</span>
</div>
))}
<div style={{ padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
<LegacyCallout>Profile editing (hardware acceleration, bitrates, tonemapping) stays in the legacy UI for now.</LegacyCallout>
</div>
</Card>
</Pane>
);
}
function PlayoutPane({ v, set }) {
return (
<Pane title="Playout" subtitle="Defaults for how far ahead playouts are built and how gaps are handled.">
<Card padded={false}>
<Row first label="Days to build" help="How many days of programming each playout keeps ahead.">
<NumInput value={v.playoutDays} onChange={(x) => set("playoutDays", x)} unit="days" />
</Row>
<Row label="Skip missing items" help="Skip items whose files are missing instead of failing the build.">
<Switch size="sm" checked={v.skipMissing} onChange={(x) => set("skipMissing", x)} />
</Row>
<Row label="Scripted schedule timeout" help="Max run time for external scripted-schedule processes.">
<NumInput value={v.scriptedTimeout} onChange={(x) => set("scriptedTimeout", x)} unit="sec" />
</Row>
</Card>
</Pane>
);
}
function XmltvPane({ v, set }) {
return (
<Pane title="Guide (XMLTV)" subtitle="Shape of the XMLTV guide data served to Jellyfin and other clients.">
<Card padded={false}>
<Row first label="Days to build" help="Guide horizon exported per channel.">
<NumInput value={v.xmltvDays} onChange={(x) => set("xmltvDays", x)} unit="days" />
</Row>
<Row label="Time zone" help="Timestamps written into the XMLTV file.">
<Select size="sm" fullWidth value={v.xmltvTimeZone} onChange={(e) => set("xmltvTimeZone", e.target.value)} options={["Local", "UTC"]} />
</Row>
<Row label="Block behavior" help="How block-schedule playouts are represented in the guide.">
<Select size="sm" fullWidth value={v.xmltvBlockBehavior} onChange={(e) => set("xmltvBlockBehavior", e.target.value)} options={["Split time evenly", "Use actual times"]} />
</Row>
</Card>
</Pane>
);
}
function ScannerPane({ v, set }) {
return (
<Pane title="Scanner" subtitle="Background scanning of local libraries.">
<Card padded={false}>
<Row first label="Library refresh interval" help="Hours between automatic local-library scans. 0 disables automatic scanning.">
<NumInput value={v.refreshHours} onChange={(x) => set("refreshHours", x)} unit="hours" />
</Row>
</Card>
{v.refreshHours === "0" && (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--status-warn-soft)", border: "1px solid transparent" }}>
<Ico n="TriangleAlert" s={14} color="var(--status-warn)" />
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
Automatic scanning is disabled — libraries only update when scanned manually.
</span>
</div>
)}
</Pane>
);
}
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 (
<Pane title="Logging" subtitle="Minimum level written per category. Verbose and Debug are noisy — use for troubleshooting only.">
<Card padded={false}>
{CATS.map((c, i) => (
<Row key={c.key} first={i === 0} label={c.label} help={c.help} control={180}>
<Select size="sm" fullWidth value={v[c.key]} onChange={(e) => set(c.key, e.target.value)} options={LOG_LEVELS} />
</Row>
))}
</Card>
</Pane>
);
}
function SystemPane({ v, set }) {
const UUID = "6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41";
return (
<Pane title="System" subtitle="HDHomeRun emulation, connected media sources and server info.">
<Card title="HDHomeRun" padded={false}>
<Row first label="Tuner count" help="Concurrent streams advertised to clients that speak HDHR.">
<NumInput value={v.tunerCount} onChange={(x) => set("tunerCount", x)} unit="tuners" />
</Row>
<Row label="Device UUID" help="Identity presented to HDHR clients." control={340}>
<div style={{ display: "flex", alignItems: "center", gap: 6, width: "100%", justifyContent: "flex-end" }}>
<span style={{ ...mono, font: "var(--text-xs)/1 var(--font-mono)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis" }}>{UUID}</span>
<IconButton size="sm" variant="ghost" title="Copy UUID"><Ico n="Copy" s={13} /></IconButton>
</div>
</Row>
</Card>
<Card title="Media sources" subtitle="Read-only — add or edit connections in the legacy UI" padded={false}>
{D.sources.map((s, i) => (
<div key={s.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<Ico n={s.kind === "Local" ? "HardDrive" : "Cast"} s={15} color="var(--text-secondary)" />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{s.name}</span>
<span style={{ marginLeft: 8, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{s.detail}</span>
</div>
<StatusDot status={s.status === "connected" ? "ok" : "warn"} size={7} />
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", width: 62, textAlign: "right" }}>{s.lastScan}</span>
</div>
))}
<div style={{ padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
<LegacyCallout>Connections, credentials and library sync are managed in the legacy UI.</LegacyCallout>
</div>
</Card>
<Card title="About" padded={false}>
<Row first label="Version" control={220}>
<span style={{ ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>v26.4.0</span>
</Row>
<Row label="Health checks" help="14 checks — 2 warnings." control={220}>
<Button size="sm" variant="secondary" endIcon={<Ico n="ArrowRight" s={13} />}>Open Dashboard</Button>
</Row>
</Card>
</Pane>
);
}
/* ---------- screen ---------- */
function Settings() {
const [section, setSection] = React.useState("general");
const [saved, setSaved] = React.useState(DEFAULTS);
const [draft, setDraft] = React.useState(DEFAULTS);
const [justSaved, setJustSaved] = React.useState(false);
const set = (k, val) => setDraft((d) => ({ ...d, [k]: val }));
const dirty = Object.keys(DEFAULTS).filter((k) => draft[k] !== saved[k]);
const PANES = {
general: GeneralPane, streaming: StreamingPane, playout: PlayoutPane,
xmltv: XmltvPane, scanner: ScannerPane, logging: LoggingPane, system: SystemPane,
};
const PaneCmp = PANES[section];
return (
<div style={{ display: "flex", height: "100%", minHeight: 0 }}>
{/* sub-nav rail */}
<div style={{ width: 224, flex: "0 0 auto", borderRight: "1px solid var(--border-hairline)", overflow: "auto", padding: "12px 8px" }}>
{SECTIONS.map((s) => {
const active = s.id === section;
return (
<button key={s.id} type="button" onClick={() => setSection(s.id)} className="ctv-press"
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "9px 10px", marginBottom: 2, border: "none", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left",
background: active ? "var(--ctv-accent-soft)" : "transparent" }}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--ctv-surface-2)"; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}>
<Ico n={s.icon} s={16} color={active ? "var(--ctv-accent)" : "var(--text-secondary)"} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: `var(--weight-medium) var(--text-sm)/1 var(--font-sans)`, color: active ? "var(--text-primary)" : "var(--text-secondary)" }}>{s.label}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.hint}</div>
</div>
</button>
);
})}
</div>
{/* pane */}
<div style={{ flex: 1, minWidth: 0, minHeight: 0, position: "relative", display: "flex", flexDirection: "column" }}>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "20px 24px 96px" }}>
<PaneCmp key={section} v={draft} set={set} />
</div>
{/* floating save bar */}
{(dirty.length > 0 || justSaved) && (
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, display: "flex", justifyContent: "center", padding: "0 24px 18px", pointerEvents: "none" }}>
<div className="ctv-lift" style={{ pointerEvents: "auto", display: "flex", alignItems: "center", gap: 14, padding: "10px 12px 10px 16px", borderRadius: "var(--radius-pill)", background: "var(--surface-raised)", border: "1px solid var(--border-control)", boxShadow: "var(--shadow-pop)", animation: "ctv-fade-in 200ms cubic-bezier(0.16,1,0.3,1)" }}>
{justSaved ? (
<>
<Ico n="CircleCheck" s={15} color="var(--status-ok)" />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Settings saved</span>
</>
) : (
<>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
{dirty.length} unsaved {dirty.length === 1 ? "change" : "changes"}
</span>
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
<Button size="sm" variant="ghost" onClick={() => setDraft(saved)}>Discard</Button>
<Button size="sm" variant="primary" startIcon={<Ico n="Check" s={14} />}
onClick={() => { setSaved(draft); setJustSaved(true); setTimeout(() => setJustSaved(false), 1800); }}>
Save changes
</Button>
</>
)}
</div>
</div>
)}
</div>
</div>
);
}
window.CTVSettings = Settings;
})();
@@ -85,6 +85,7 @@
<script type="text/babel" src="Epg.jsx" data-presets="react"></script>
<script type="text/babel" src="Playouts.jsx" data-presets="react"></script>
<script type="text/babel" src="ScheduleLibrary.jsx" data-presets="react"></script>
<script type="text/babel" src="Settings.jsx" data-presets="react"></script>
<script type="text/babel" data-presets="react">
const { Sidebar, TopBar } = window.CTVShell;
@@ -113,6 +114,7 @@ function App() {
schedules: window.CTVScheduleEditor,
playouts: window.CTVPlayouts,
libraries: window.CTVLibraries,
settings: window.CTVSettings,
};
const Screen = screens[view];
return (
@@ -131,7 +133,7 @@ function App() {
function mount() {
if (!window.ChicoryTVDesignSystem_eb3b61 || !window.CTVShell || !window.CTVDashboard ||
!window.CTVChannels || !window.CTVChannelBuilder || !window.CTVScheduleEditor || !window.CTVEpg ||
!window.CTVPlayouts || !window.CTVLibraries) {
!window.CTVPlayouts || !window.CTVLibraries || !window.CTVSettings) {
return setTimeout(mount, 40);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
+597 -1
View File
@@ -2573,6 +2573,361 @@ describe('ChicoryTV SPA scaffold', () => {
});
});
describe('Settings screen (#93)', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
window.history.replaceState(null, '', '/');
});
beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute('data-theme');
window.history.replaceState(null, '', '/app');
vi.restoreAllMocks();
});
const openSettings = async () => {
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Settings' }));
expect(
await screen.findByText('Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.')
).toBeInTheDocument();
};
it('renders each section on nav click', async () => {
mockDashboardApi();
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Settings' }));
expect(await screen.findByText('Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Streaming/ }));
expect(await screen.findByText('FFmpeg engine, transcoding defaults and HLS session tuning.')).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/settings/streaming');
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
expect(await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Guide \(XMLTV\)/ }));
expect(await screen.findByText('Shape of the XMLTV guide data served to Jellyfin and other clients.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Scanner/ }));
expect(await screen.findByText('Background scanning of local libraries.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Logging/ }));
expect(await screen.findByText('Minimum level written per category. Verbose and Debug are noisy — use for troubleshooting only.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
expect(await screen.findByText('HDHomeRun emulation, connected media sources and server info.')).toBeInTheDocument();
});
it('supports deep-linking directly to a section path', async () => {
mockDashboardApi();
window.history.replaceState(null, '', '/app/settings/scanner');
render(<App />);
expect(await screen.findByText('Background scanning of local libraries.')).toBeInTheDocument();
});
it('loads values from the API into the General pane', async () => {
mockDashboardApi({ uiSettings: defaultUiSettings({ isDarkMode: false, language: 'fr' }) });
await openSettings();
expect(await screen.findByDisplayValue('Light')).toBeInTheDocument();
expect(screen.getByDisplayValue('fr')).toBeInTheDocument();
});
it('shows the save bar with the correct dirty count when editing a field, and Discard restores', async () => {
mockDashboardApi();
await openSettings();
const languageInput = screen.getByPlaceholderText('en-US');
fireEvent.change(languageInput, { target: { value: 'de-DE' } });
expect(await screen.findByText('1 unsaved change')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(screen.queryByText('1 unsaved change')).not.toBeInTheDocument();
expect(screen.getByDisplayValue('en-US')).toBeInTheDocument();
});
it('saves only the changed group(s) and shows a confirmation', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockDashboardApi();
await openSettings();
const languageInput = screen.getByPlaceholderText('en-US');
fireEvent.change(languageInput, { target: { value: 'de-DE' } });
await screen.findByText('1 unsaved change');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/settings/ui')).toMatchObject({ language: 'de-DE' });
expect(window.fetch).not.toHaveBeenCalledWith('/api/settings/ffmpeg', expect.objectContaining({ method: 'PUT' }));
vi.advanceTimersByTime(2000);
await waitFor(() => expect(screen.queryByText('Settings saved')).not.toBeInTheDocument());
});
it('shows a warning callout when scanner refresh interval is 0', async () => {
mockDashboardApi({ scannerSettings: defaultScannerSettings({ libraryRefreshInterval: 0 }) });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Scanner/ }));
expect(await screen.findByText('Automatic scanning is disabled — libraries only update when scanned manually.')).toBeInTheDocument();
});
it('adds and deletes a custom resolution (delete goes through a confirm dialog)', async () => {
mockDashboardApi();
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('Custom resolutions');
fireEvent.change(screen.getByPlaceholderText('width'), { target: { value: '2560' } });
fireEvent.change(screen.getByPlaceholderText('height'), { target: { value: '1080' } });
fireEvent.click(screen.getByRole('button', { name: 'Add resolution' }));
expect(await screen.findByText('2560 × 1080')).toBeInTheDocument();
expect(requestBodyFor('/api/settings/resolutions')).toMatchObject({ height: 1080, width: 2560 });
const deleteButtons = screen.getAllByRole('button', { name: /Delete \d+×\d+/ });
fireEvent.click(deleteButtons[deleteButtons.length - 1]);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(screen.queryByText('2560 × 1080')).not.toBeInTheDocument());
});
it('does not show a delete button on non-custom resolutions', async () => {
mockDashboardApi();
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('1920 × 1080');
expect(screen.queryByRole('button', { name: 'Delete 1920×1080' })).not.toBeInTheDocument();
});
it('renders FFmpeg profiles and media sources as read-only cards with a Legacy UI callout', async () => {
mockDashboardApi({
ffmpegProfiles: [ffmpegProfile({ id: 1, name: '1080p H.264' }), ffmpegProfile({ id: 2, name: '720p H.264' })],
mediaSources: [mediaSource({ id: 30, kind: 'Local', name: 'Local' })]
});
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
expect((await screen.findAllByText('1080p H.264')).length).toBeGreaterThan(0);
expect(screen.getAllByText('720p H.264').length).toBeGreaterThan(0);
expect(screen.getAllByText('Legacy UI').length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
expect(await screen.findByText('Local')).toBeInTheDocument();
});
it('renders and stays editable when media sources (tier-2 reference data) fail to load', async () => {
mockDashboardApi({ mediaSourcesFailuresBeforeSuccess: 99 });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
expect(await screen.findByText("Couldn't load media sources")).toBeInTheDocument();
// The rest of the screen is fully functional: edit + save still work.
fireEvent.click(screen.getByRole('button', { name: /General/ }));
fireEvent.change(await screen.findByPlaceholderText('en-US'), { target: { value: 'nl-BE' } });
await screen.findByText('1 unsaved change');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/settings/ui')).toMatchObject({ language: 'nl-BE' });
});
it('shows the error state with a working retry when a settings group (tier-1) fails', async () => {
mockDashboardApi({ settingsGetFailuresBeforeSuccess: { '/api/settings/scanner': 1 } });
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Settings' }));
const alert = await screen.findByRole('alert');
expect(alert).toBeInTheDocument();
expect(screen.queryByText('Loading settings…')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(
await screen.findByText('Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.')
).toBeInTheDocument();
});
it('saves exactly the dirty groups when multiple groups are edited', async () => {
mockDashboardApi();
await openSettings();
fireEvent.change(screen.getByPlaceholderText('en-US'), { target: { value: 'de-DE' } });
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.');
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '5' } });
await screen.findByText('2 unsaved changes');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/settings/ui')).toMatchObject({ language: 'de-DE' });
expect(requestBodyFor('/api/settings/playout')).toMatchObject({ daysToBuild: 5 });
const putCallCount = (path: string) =>
vi.mocked(window.fetch).mock.calls.filter(([input, init]) => input.toString() === path && init?.method === 'PUT')
.length;
expect(putCallCount('/api/settings/ui')).toBe(1);
expect(putCallCount('/api/settings/playout')).toBe(1);
expect(putCallCount('/api/settings/ffmpeg')).toBe(0);
expect(putCallCount('/api/settings/xmltv')).toBe(0);
expect(putCallCount('/api/settings/scanner')).toBe(0);
expect(putCallCount('/api/settings/logging')).toBe(0);
expect(putCallCount('/api/settings/hdhr')).toBe(0);
});
it('on partial save failure, keeps only the failed group dirty and surfaces its error', async () => {
mockDashboardApi({
settingsMutationFailures: { '/api/settings/playout': { detail: 'Playout save failed', status: 500 } }
});
await openSettings();
fireEvent.change(screen.getByPlaceholderText('en-US'), { target: { value: 'de-DE' } });
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.');
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '5' } });
await screen.findByText('2 unsaved changes');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('1 unsaved change')).toBeInTheDocument();
expect(screen.getByText(/Playout save failed/)).toBeInTheDocument();
expect(screen.queryByText('Settings saved')).not.toBeInTheDocument();
// The succeeded (ui) group's edit stuck even though the draft stayed on the Playout pane.
fireEvent.click(screen.getByRole('button', { name: /General/ }));
expect(await screen.findByDisplayValue('de-DE')).toBeInTheDocument();
});
it('surfaces an inline error (no unhandled rejection) when adding a duplicate resolution fails', async () => {
mockDashboardApi({ resolutionCreateFailure: { detail: 'Resolution already exists', status: 422 } });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('Custom resolutions');
fireEvent.change(screen.getByPlaceholderText('width'), { target: { value: '2560' } });
fireEvent.change(screen.getByPlaceholderText('height'), { target: { value: '1080' } });
fireEvent.click(screen.getByRole('button', { name: 'Add resolution' }));
expect(await screen.findByText('Resolution already exists')).toBeInTheDocument();
});
it('surfaces a resolution-delete failure inside the still-open confirm dialog', async () => {
mockDashboardApi({ resolutionDeleteFailure: { detail: 'Resolution is in use', status: 409 } });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('1920 × 1080');
fireEvent.change(screen.getByPlaceholderText('width'), { target: { value: '2560' } });
fireEvent.change(screen.getByPlaceholderText('height'), { target: { value: '1080' } });
fireEvent.click(screen.getByRole('button', { name: 'Add resolution' }));
await screen.findByText('2560 × 1080');
const deleteButtons = screen.getAllByRole('button', { name: /Delete \d+×\d+/ });
fireEvent.click(deleteButtons[deleteButtons.length - 1]);
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
expect(await within(dialog).findByText('Resolution is in use')).toBeInTheDocument();
// The dialog stays open with the resolution still present - a clean draft would
// otherwise show nothing at all, since saveError only renders inside the save bar.
expect(screen.getByText('2560 × 1080')).toBeInTheDocument();
});
it('disables Save and shows an inline error when a numeric field is cleared', async () => {
mockDashboardApi();
await openSettings();
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.');
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '' } });
await screen.findByText('1 unsaved change');
expect(screen.getByText('Must be a whole number ≥ 0')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save changes/ })).toBeDisabled();
});
it('treats a tunerCount of 0 as invalid (backend rejects 0)', async () => {
mockDashboardApi();
await openSettings();
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
await screen.findByText('HDHomeRun emulation, connected media sources and server info.');
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '0' } });
await screen.findByText('1 unsaved change');
expect(screen.getByText('Must be a whole number ≥ 1')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save changes/ })).toBeDisabled();
});
it('shows the raw wire value as an extra HLS Direct option when it is outside the known set', async () => {
mockDashboardApi({ ffmpegSettings: defaultFfmpegSettings({ hlsDirectOutputFormat: 'Hls' }) });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
expect(await screen.findByDisplayValue('Hls')).toBeInTheDocument();
});
it('sends a non-null globalWatermarkId through in the ffmpeg save PUT body', async () => {
mockDashboardApi({ watermarks: [{ id: 5, name: 'Bug' }] });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('Global defaults');
const watermarkSelect = screen
.getAllByRole('combobox')
.find((select) => within(select).queryByText('Bug')) as HTMLSelectElement;
fireEvent.change(watermarkSelect, { target: { value: '5' } });
await screen.findByText('1 unsaved change');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/settings/ffmpeg')).toMatchObject({ globalWatermarkId: 5 });
});
it('renders the most recent last-scan time per media source', async () => {
mockDashboardApi({
mediaSources: [
mediaSource({
id: 30,
kind: 'Local',
libraries: [
library({ id: 31, lastScan: '2026-07-01T00:00:00Z' }),
library({ id: 32, lastScan: '2026-07-05T14:30:00Z' })
],
name: 'Local'
}),
mediaSource({ id: 40, kind: 'Jellyfin', libraries: [], name: 'Jellyfin Server' })
]
});
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
expect(await screen.findByText('Local')).toBeInTheDocument();
expect(await screen.findByText('Jellyfin Server')).toBeInTheDocument();
expect(screen.getByText(/Last scan/)).toBeInTheDocument();
});
});
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
@@ -2738,6 +3093,88 @@ function playout(overrides: Record<string, unknown> = {}): Record<string, unknow
});
}
// ---- Settings (#93) fixtures ----
function defaultFfmpegSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
defaultFFmpegProfileId: 1,
defaultMpegTsScript: '',
extractEmbeddedSubtitles: false,
fFmpegPath: '/usr/bin/ffmpeg',
fFprobePath: '/usr/bin/ffprobe',
globalFallbackFillerId: null,
globalWatermarkId: null,
hlsDirectOutputFormat: 'MpegTs',
hlsSegmenterIdleTimeout: 60,
initialSegmentCount: 1,
preferredAudioLanguageCode: 'eng',
probeForInterlacedFrames: true,
saveReports: false,
useEmbeddedSubtitles: true,
workAheadSegmenterLimit: 1,
...overrides
};
}
function defaultPlayoutSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
daysToBuild: 2,
scriptedScheduleTimeoutSeconds: 30,
skipMissingItems: true,
...overrides
};
}
function defaultXmltvSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
blockBehavior: 'SplitTimeEvenly',
daysToBuild: 2,
timeZone: 'Local',
...overrides
};
}
function defaultScannerSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
libraryRefreshInterval: 6,
...overrides
};
}
function defaultLoggingSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
defaultMinimumLogLevel: 'Information',
httpMinimumLogLevel: 'Warning',
scanningMinimumLogLevel: 'Information',
schedulingMinimumLogLevel: 'Information',
searchingMinimumLogLevel: 'Information',
streamingMinimumLogLevel: 'Information',
...overrides
};
}
function defaultUiSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
isDarkMode: true,
language: 'en-US',
...overrides
};
}
function defaultHdhrSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
tunerCount: 2,
uuid: '6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41',
...overrides
};
}
function defaultResolutions(): Array<Record<string, unknown>> {
return [
{ height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 },
{ height: 720, id: 2, isCustom: false, name: '1280x720', width: 1280 }
];
}
// The backend always emits fillerKind: real content items carry 'None' (never null).
function playoutItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
@@ -2898,7 +3335,19 @@ function mockDashboardApi({
scheduleItemsTotalDuration = null,
schedules = [],
smartCollections = [],
version = { apiVersion: 3, appVersion: '26.4.0' }
version = { apiVersion: 3, appVersion: '26.4.0' },
ffmpegSettings = defaultFfmpegSettings(),
playoutSettings = defaultPlayoutSettings(),
xmltvSettings = defaultXmltvSettings(),
scannerSettings = defaultScannerSettings(),
loggingSettings = defaultLoggingSettings(),
uiSettings = defaultUiSettings(),
hdhrSettings = defaultHdhrSettings(),
resolutions = defaultResolutions(),
resolutionCreateFailure = null,
resolutionDeleteFailure = null,
settingsMutationFailures = {},
settingsGetFailuresBeforeSuccess = {}
}: {
addScheduleItemResponse?: unknown;
artworkUploadFailure?: { status: number } | null;
@@ -2946,12 +3395,35 @@ function mockDashboardApi({
schedules?: unknown[];
smartCollections?: unknown[];
version?: unknown;
ffmpegSettings?: Record<string, unknown>;
playoutSettings?: Record<string, unknown>;
xmltvSettings?: Record<string, unknown>;
scannerSettings?: Record<string, unknown>;
loggingSettings?: Record<string, unknown>;
uiSettings?: Record<string, unknown>;
hdhrSettings?: Record<string, unknown>;
resolutions?: Array<Record<string, unknown>>;
resolutionCreateFailure?: { detail?: string; status?: number; title?: string } | null;
resolutionDeleteFailure?: { detail?: string; status?: number; title?: string } | null;
settingsMutationFailures?: Record<string, { detail?: string; status?: number; title?: string }>;
// path -> number of GET failures (500) before the endpoint recovers; use a large
// number for a persistently-failing endpoint.
settingsGetFailuresBeforeSuccess?: Record<string, number>;
} = {}) {
vi.spyOn(window, 'confirm').mockReturnValue(confirm);
vi.spyOn(window, 'prompt').mockReturnValue(prompt);
let currentLibraryScanStatuses = libraryScanStatuses;
let currentChannelTemplates = channelTemplates;
let currentScheduleItems = scheduleItems;
const remainingSettingsGetFailures: Record<string, number> = { ...settingsGetFailuresBeforeSuccess };
let currentFfmpegSettings = ffmpegSettings;
let currentPlayoutSettings = playoutSettings;
let currentXmltvSettings = xmltvSettings;
let currentScannerSettings = scannerSettings;
let currentLoggingSettings = loggingSettings;
let currentUiSettings = uiSettings;
let currentHdhrSettings = hdhrSettings;
let currentResolutions = resolutions;
let remainingMediaSourcesFailures = mediaSourcesFailuresBeforeSuccess;
let remainingGuideFailures = guideFailuresBeforeSuccess;
let remainingScheduleItemFailures = scheduleItemFailuresBeforeSuccess;
@@ -3183,6 +3655,130 @@ function mockDashboardApi({
return Promise.resolve(jsonResponse(version));
}
// ---- Settings (#93) endpoints ----
if (
path.startsWith('/api/settings/') &&
(init?.method ?? 'GET') === 'GET' &&
(remainingSettingsGetFailures[path] ?? 0) > 0
) {
remainingSettingsGetFailures[path] -= 1;
return Promise.resolve(new Response(null, { status: 500 }));
}
if (path === '/api/settings/ffmpeg') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentFfmpegSettings = { ...currentFfmpegSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentFfmpegSettings));
}
return Promise.resolve(jsonResponse(currentFfmpegSettings));
}
if (path === '/api/settings/playout') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentPlayoutSettings = { ...currentPlayoutSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentPlayoutSettings));
}
return Promise.resolve(jsonResponse(currentPlayoutSettings));
}
if (path === '/api/settings/xmltv') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentXmltvSettings = { ...currentXmltvSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentXmltvSettings));
}
return Promise.resolve(jsonResponse(currentXmltvSettings));
}
if (path === '/api/settings/scanner') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentScannerSettings = { ...currentScannerSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentScannerSettings));
}
return Promise.resolve(jsonResponse(currentScannerSettings));
}
if (path === '/api/settings/logging') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentLoggingSettings = { ...currentLoggingSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentLoggingSettings));
}
return Promise.resolve(jsonResponse(currentLoggingSettings));
}
if (path === '/api/settings/ui') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentUiSettings = { ...currentUiSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentUiSettings));
}
return Promise.resolve(jsonResponse(currentUiSettings));
}
if (path === '/api/settings/hdhr') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentHdhrSettings = { ...currentHdhrSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentHdhrSettings));
}
return Promise.resolve(jsonResponse(currentHdhrSettings));
}
if (path === '/api/settings/resolutions') {
if ((init?.method ?? 'GET') === 'POST') {
if (resolutionCreateFailure) {
return Promise.resolve(jsonResponse(resolutionCreateFailure, resolutionCreateFailure.status ?? 422));
}
const body = JSON.parse(init?.body as string) as { height: number; width: number };
const created = {
height: body.height,
id: Math.max(0, ...currentResolutions.map((resolution) => Number(resolution.id))) + 1,
isCustom: true,
name: `${body.width}x${body.height}`,
width: body.width
};
currentResolutions = [...currentResolutions, created];
return Promise.resolve(jsonResponse(created, 201));
}
return Promise.resolve(jsonResponse(currentResolutions));
}
if (path.match(/^\/api\/settings\/resolutions\/\d+$/)) {
if (resolutionDeleteFailure) {
return Promise.resolve(jsonResponse(resolutionDeleteFailure, resolutionDeleteFailure.status ?? 422));
}
const id = Number(path.split('/').at(-1));
currentResolutions = currentResolutions.filter((resolution) => Number(resolution.id) !== id);
return Promise.resolve(new Response(null, { status: 204 }));
}
if (path in mutationFailures) {
return Promise.resolve(jsonResponse(mutationFailures[path], 422));
}
+23 -2
View File
@@ -54,6 +54,7 @@ import {
} from 'lucide-react';
import chicoryIconUrl from '../../design-system/assets/chicorytv-icon.svg';
import { ChannelBuilderScreen } from './builder/ChannelBuilder';
import { SettingsScreen } from './screens/SettingsScreen';
import { navigateToPath } from './routing';
import {
Badge,
@@ -141,6 +142,9 @@ interface ScreenRoute {
primaryAction: string;
placeholder: string;
badge?: number;
// Screens that own linkable sub-sections (e.g. /app/settings/streaming) opt in here;
// the sub-path is otherwise unowned and 404s (see routeFromLocation).
allowSubPaths?: boolean;
}
const routes: ScreenRoute[] = [
@@ -242,7 +246,8 @@ const routes: ScreenRoute[] = [
description: 'Server configuration, access keys, FFmpeg profiles, and UI preferences.',
icon: <Settings aria-hidden="true" size={16} />,
primaryAction: 'Save Changes',
placeholder: 'Settings workspace'
placeholder: 'Settings workspace',
allowSubPaths: true
}
];
@@ -265,8 +270,20 @@ function normalizePath(pathname: string): string {
function routeFromLocation(): ScreenRoute | null {
const pathname = normalizePath(window.location.pathname);
const exactMatch = routes.find((route) => route.path === pathname);
return routes.find((route) => route.path === pathname) ?? null;
if (exactMatch) {
return exactMatch;
}
// Fall back to a sub-path of a route that has explicitly opted into linkable
// sub-sections (e.g. /app/settings/streaming) - the screen owns parsing the
// suffix itself (see SettingsScreen).
const prefixMatches = routes
.filter((route) => route.allowSubPaths && pathname.startsWith(`${route.path}/`))
.sort((a, b) => b.path.length - a.path.length);
return prefixMatches[0] ?? null;
}
function routeHref(route: ScreenRoute): string {
@@ -2840,6 +2857,10 @@ function ScreenContent({
return <LibrariesScreen />;
}
if (route.id === 'settings') {
return <SettingsScreen />;
}
return <PlaceholderScreen route={route} />;
}
+106
View File
@@ -307,6 +307,10 @@ export interface components {
"CreatePlayoutRequest": {
"channelId": number;
"programScheduleId": number;
};
"CreateResolutionRequest": {
"width": number;
"height": number;
};
"CreateScheduleRequest": {
"name": null | string;
@@ -352,6 +356,23 @@ export interface components {
"FFmpegProfileBitDepth": "EightBit" | "TenBit";
"FFmpegProfileTonemapAlgorithm": "Linear" | "Clip" | "Gamma" | "Reinhard" | "Mobius" | "Hable";
"FFmpegProfileVideoFormat": "None" | "H264" | "Hevc" | "Mpeg2Video" | "Av1" | "Copy";
"FFmpegSettingsResponseModel": {
"fFmpegPath": string;
"fFprobePath": string;
"defaultFFmpegProfileId": number;
"preferredAudioLanguageCode": string;
"useEmbeddedSubtitles": boolean;
"extractEmbeddedSubtitles": boolean;
"probeForInterlacedFrames": boolean;
"saveReports": boolean;
"globalWatermarkId": null | number;
"globalFallbackFillerId": null | number;
"hlsSegmenterIdleTimeout": number;
"workAheadSegmenterLimit": number;
"initialSegmentCount": number;
"hlsDirectOutputFormat": components["schemas"]["OutputFormatKind"];
"defaultMpegTsScript": string;
};
"FillerKind": "None" | "PreRoll" | "MidRoll" | "PostRoll" | "Tail" | "Fallback" | "GuideMode" | "DecoDefault";
"FillerMode": "None" | "Duration" | "Count" | "Pad" | "RandomCount";
"FillerPresetResponseModel": {
@@ -390,6 +411,10 @@ export interface components {
};
"GuideMode": "Normal" | "Filler";
"HardwareAccelerationKind": "None" | "Qsv" | "Nvenc" | "Vaapi" | "VideoToolbox" | "Amf" | "V4l2m2m" | "Rkmpp";
"HdhrSettingsResponseModel": {
"tunerCount": number;
"uuid": string;
};
"HealthCheckResponseModel": {
"title": string;
"status": string;
@@ -426,6 +451,15 @@ export interface components {
"LibraryScanStatusResponseModel": {
"libraryId": number;
"percent": number;
};
"LogEventLevel": "Verbose" | "Debug" | "Information" | "Warning" | "Error" | "Fatal";
"LoggingSettingsResponseModel": {
"defaultMinimumLogLevel": components["schemas"]["LogEventLevel"];
"scanningMinimumLogLevel": components["schemas"]["LogEventLevel"];
"schedulingMinimumLogLevel": components["schemas"]["LogEventLevel"];
"searchingMinimumLogLevel": components["schemas"]["LogEventLevel"];
"streamingMinimumLogLevel": components["schemas"]["LogEventLevel"];
"httpMinimumLogLevel": components["schemas"]["LogEventLevel"];
};
"MarathonGroupBy": "None" | "Show" | "Season" | "Artist" | "Album" | "Director";
"MediaCollectionViewModel": {
@@ -480,6 +514,7 @@ export interface components {
"name": null | string;
};
"NormalizeLoudnessMode": "Off" | "LoudNorm";
"OutputFormatKind": "None" | "Mkv" | "MpegTs" | "Mp4" | "Hls" | "HlsMp4" | "Nut";
"PagedLibraryBrowseItemsResponseModel": {
"totalCount": number;
"page": Array<components["schemas"]["LibraryBrowseItemResponseModel"]>;
@@ -534,6 +569,11 @@ export interface components {
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
};
"PlayoutScheduleKind": "None" | "Classic" | "Block" | "Sequential" | "Scripted" | "ExternalJson";
"PlayoutSettingsResponseModel": {
"daysToBuild": number;
"skipMissingItems": boolean;
"scriptedScheduleTimeoutSeconds": number;
};
"ProblemDetails": {
"type"?: null | string;
"title"?: null | string;
@@ -605,6 +645,13 @@ export interface components {
"mediaItem": components["schemas"]["NamedMediaItemViewModel"];
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
};
"ResolutionResponseModel": {
"id": number;
"name": string;
"width": number;
"height": number;
"isCustom": boolean;
};
"ResolutionViewModel": {
"id": number;
@@ -614,6 +661,9 @@ export interface components {
"isCustom": boolean;
};
"ScalingBehavior": "ScaleAndPad" | "Stretch" | "Crop";
"ScannerSettingsResponseModel": {
"libraryRefreshInterval": number;
};
"ScanShowRequest": {
"showTitle": null | string;
"deepScan"?: boolean;
@@ -670,6 +720,10 @@ export interface components {
"StartType": "Dynamic" | "Fixed";
"StreamingMode": "TransportStream" | "HttpLiveStreamingDirect" | "HttpLiveStreamingSegmenter" | "TransportStreamHybrid";
"TailMode": "None" | "Offline" | "Slate" | "Filler";
"UiSettingsResponseModel": {
"isDarkMode": boolean;
"language": string;
};
"UpdateChannelRequest": {
"name": null | string;
"number": null | string;
@@ -761,6 +815,42 @@ export interface components {
"normalizeFramerate": boolean;
"normalizeColors": boolean;
"deinterlaceVideo": boolean;
};
"UpdateFFmpegSettingsRequest": {
"fFmpegPath": null | string;
"fFprobePath": null | string;
"defaultFFmpegProfileId": number;
"preferredAudioLanguageCode": null | string;
"useEmbeddedSubtitles": boolean;
"extractEmbeddedSubtitles": boolean;
"probeForInterlacedFrames": boolean;
"saveReports": boolean;
"globalWatermarkId": null | number;
"globalFallbackFillerId": null | number;
"hlsSegmenterIdleTimeout": number;
"workAheadSegmenterLimit": number;
"initialSegmentCount": number;
"hlsDirectOutputFormat": components["schemas"]["OutputFormatKind"];
"defaultMpegTsScript": null | string;
};
"UpdateHdhrSettingsRequest": {
"tunerCount": number;
};
"UpdateLoggingSettingsRequest": {
"defaultMinimumLogLevel": components["schemas"]["LogEventLevel"];
"scanningMinimumLogLevel": components["schemas"]["LogEventLevel"];
"schedulingMinimumLogLevel": components["schemas"]["LogEventLevel"];
"searchingMinimumLogLevel": components["schemas"]["LogEventLevel"];
"streamingMinimumLogLevel": components["schemas"]["LogEventLevel"];
"httpMinimumLogLevel": components["schemas"]["LogEventLevel"];
};
"UpdatePlayoutSettingsRequest": {
"daysToBuild": number;
"skipMissingItems": boolean;
"scriptedScheduleTimeoutSeconds": number;
};
"UpdateScannerSettingsRequest": {
"libraryRefreshInterval": number;
};
"UpdateScheduleRequest": {
"name": null | string;
@@ -773,6 +863,15 @@ export interface components {
"UpdateSmartCollectionRequest": {
"name": null | string;
"query": null | string;
};
"UpdateUiSettingsRequest": {
"isDarkMode": boolean;
"language": null | string;
};
"UpdateXmltvSettingsRequest": {
"daysToBuild": number;
"timeZone": components["schemas"]["XmltvTimeZone"];
"blockBehavior": components["schemas"]["XmltvBlockBehavior"];
};
"VaapiDriver": "Default" | "iHD" | "i965" | "RadeonSI" | "Nouveau";
"WatermarkLocation": number;
@@ -799,6 +898,13 @@ export interface components {
"opacityExpression": null | string;
"zIndex": number;
};
"XmltvBlockBehavior": "SplitTimeEvenly" | "UseActualTimes";
"XmltvSettingsResponseModel": {
"daysToBuild": number;
"timeZone": components["schemas"]["XmltvTimeZone"];
"blockBehavior": components["schemas"]["XmltvBlockBehavior"];
};
"XmltvTimeZone": "Local" | "Utc";
};
}
+1
View File
@@ -10,4 +10,5 @@ export * from './libraryBrowse';
export * from './pickers';
export * from './playouts';
export * from './schedules';
export * from './settings';
export * from './useChannelsQuery';
+188
View File
@@ -0,0 +1,188 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createResolution,
deleteResolution,
getFfmpegSettings,
getHdhrSettings,
getLoggingSettings,
getPlayoutSettings,
getResolutions,
getScannerSettings,
getUiSettings,
getXmltvSettings,
messageFromSettingsError,
updateFfmpegSettings,
updateHdhrSettings,
updateLoggingSettings,
updatePlayoutSettings,
updateScannerSettings,
updateUiSettings,
updateXmltvSettings
} from './settings';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
function lastFetchCall(): [RequestInfo | URL, RequestInit | undefined] {
const calls = vi.mocked(window.fetch).mock.calls;
return calls[calls.length - 1] as [RequestInfo | URL, RequestInit | undefined];
}
describe('settings API module', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('fetches ffmpeg settings from GET /api/settings/ffmpeg', async () => {
const settings = { fFmpegPath: '/usr/bin/ffmpeg' };
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getFfmpegSettings()).resolves.toMatchObject(settings);
const [path] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/ffmpeg');
});
it('PUTs ffmpeg settings to /api/settings/ffmpeg', async () => {
const body = { fFmpegPath: '/usr/bin/ffmpeg', hlsDirectOutputFormat: 'MpegTs' as const };
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(body));
await updateFfmpegSettings(body as never);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/ffmpeg');
expect(init?.method).toBe('PUT');
expect(JSON.parse(init?.body as string)).toMatchObject({ fFmpegPath: '/usr/bin/ffmpeg' });
});
it('fetches and updates playout settings', async () => {
const settings = { daysToBuild: 2, scriptedScheduleTimeoutSeconds: 30, skipMissingItems: true };
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getPlayoutSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/playout');
await updatePlayoutSettings(settings);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/playout');
expect(init?.method).toBe('PUT');
});
it('fetches and updates xmltv settings', async () => {
const settings = { blockBehavior: 'SplitTimeEvenly' as const, daysToBuild: 2, timeZone: 'Local' as const };
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getXmltvSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/xmltv');
await updateXmltvSettings(settings);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/xmltv');
expect(init?.method).toBe('PUT');
});
it('fetches and updates scanner settings', async () => {
const settings = { libraryRefreshInterval: 6 };
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getScannerSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/scanner');
await updateScannerSettings(settings);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/scanner');
expect(init?.method).toBe('PUT');
});
it('fetches and updates logging settings', async () => {
const settings = {
defaultMinimumLogLevel: 'Information' as const,
httpMinimumLogLevel: 'Warning' as const,
scanningMinimumLogLevel: 'Information' as const,
schedulingMinimumLogLevel: 'Information' as const,
searchingMinimumLogLevel: 'Information' as const,
streamingMinimumLogLevel: 'Information' as const
};
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getLoggingSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/logging');
await updateLoggingSettings(settings);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/logging');
expect(init?.method).toBe('PUT');
});
it('fetches and updates UI settings', async () => {
const settings = { isDarkMode: true, language: 'en-US' };
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getUiSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/ui');
await updateUiSettings(settings);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/ui');
expect(init?.method).toBe('PUT');
});
it('fetches and updates HDHR settings', async () => {
const settings = { tunerCount: 2, uuid: 'abc-123' };
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getHdhrSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/hdhr');
await updateHdhrSettings({ tunerCount: 2 });
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/hdhr');
expect(init?.method).toBe('PUT');
expect(JSON.parse(init?.body as string)).toMatchObject({ tunerCount: 2 });
});
it('fetches resolutions from GET /api/settings/resolutions', async () => {
const resolutions = [{ height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 }];
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(resolutions));
await expect(getResolutions()).resolves.toMatchObject(resolutions);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/resolutions');
});
it('creates a custom resolution via POST /api/settings/resolutions', async () => {
const created = { height: 1080, id: 3, isCustom: true, name: '2560x1080', width: 2560 };
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(created, 201));
await expect(createResolution({ height: 1080, width: 2560 })).resolves.toMatchObject(created);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/resolutions');
expect(init?.method).toBe('POST');
expect(JSON.parse(init?.body as string)).toMatchObject({ height: 1080, width: 2560 });
});
it('deletes a custom resolution via DELETE /api/settings/resolutions/{id}', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
await deleteResolution(3);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/resolutions/3');
expect(init?.method).toBe('DELETE');
});
describe('messageFromSettingsError', () => {
it('prefers the ApiError detail over the fallback message', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ detail: 'ffmpeg path does not exist', title: 'Unprocessable' }, 422));
await expect(getFfmpegSettings()).rejects.toMatchObject({ detail: 'ffmpeg path does not exist' });
});
it('falls back to a default message for unknown errors', () => {
expect(messageFromSettingsError('boom', 'Unable to load settings')).toBe('Unable to load settings');
});
});
});
+284
View File
@@ -0,0 +1,284 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import { getDashboardHealth, getDashboardVersion, type DashboardVersion } from './dashboard';
import { getMediaSources, type MediaSource } from './libraries';
import { getFFmpegProfiles, getFillerPresets, getWatermarks, type FFmpegProfile, type FillerPreset, type Watermark } from './pickers';
import type { components } from './generated/v1';
export type FfmpegSettings = components['schemas']['FFmpegSettingsResponseModel'];
export type UpdateFfmpegSettingsRequest = components['schemas']['UpdateFFmpegSettingsRequest'];
export type PlayoutSettings = components['schemas']['PlayoutSettingsResponseModel'];
export type UpdatePlayoutSettingsRequest = components['schemas']['UpdatePlayoutSettingsRequest'];
export type XmltvSettings = components['schemas']['XmltvSettingsResponseModel'];
export type UpdateXmltvSettingsRequest = components['schemas']['UpdateXmltvSettingsRequest'];
export type ScannerSettings = components['schemas']['ScannerSettingsResponseModel'];
export type UpdateScannerSettingsRequest = components['schemas']['UpdateScannerSettingsRequest'];
export type LoggingSettings = components['schemas']['LoggingSettingsResponseModel'];
export type UpdateLoggingSettingsRequest = components['schemas']['UpdateLoggingSettingsRequest'];
export type UiSettings = components['schemas']['UiSettingsResponseModel'];
export type UpdateUiSettingsRequest = components['schemas']['UpdateUiSettingsRequest'];
export type HdhrSettings = components['schemas']['HdhrSettingsResponseModel'];
export type UpdateHdhrSettingsRequest = components['schemas']['UpdateHdhrSettingsRequest'];
export type Resolution = components['schemas']['ResolutionResponseModel'];
export type CreateResolutionRequest = components['schemas']['CreateResolutionRequest'];
export type LogEventLevel = components['schemas']['LogEventLevel'];
export type XmltvTimeZone = components['schemas']['XmltvTimeZone'];
export type XmltvBlockBehavior = components['schemas']['XmltvBlockBehavior'];
export type OutputFormatKind = components['schemas']['OutputFormatKind'];
export type HealthCheck = components['schemas']['HealthCheckResponseModel'];
export function getFfmpegSettings(): Promise<FfmpegSettings> {
return request<FfmpegSettings>('/api/settings/ffmpeg');
}
export function updateFfmpegSettings(body: UpdateFfmpegSettingsRequest): Promise<FfmpegSettings> {
return request<FfmpegSettings>('/api/settings/ffmpeg', { body, method: 'PUT' });
}
export function getPlayoutSettings(): Promise<PlayoutSettings> {
return request<PlayoutSettings>('/api/settings/playout');
}
export function updatePlayoutSettings(body: UpdatePlayoutSettingsRequest): Promise<PlayoutSettings> {
return request<PlayoutSettings>('/api/settings/playout', { body, method: 'PUT' });
}
export function getXmltvSettings(): Promise<XmltvSettings> {
return request<XmltvSettings>('/api/settings/xmltv');
}
export function updateXmltvSettings(body: UpdateXmltvSettingsRequest): Promise<XmltvSettings> {
return request<XmltvSettings>('/api/settings/xmltv', { body, method: 'PUT' });
}
export function getScannerSettings(): Promise<ScannerSettings> {
return request<ScannerSettings>('/api/settings/scanner');
}
export function updateScannerSettings(body: UpdateScannerSettingsRequest): Promise<ScannerSettings> {
return request<ScannerSettings>('/api/settings/scanner', { body, method: 'PUT' });
}
export function getLoggingSettings(): Promise<LoggingSettings> {
return request<LoggingSettings>('/api/settings/logging');
}
export function updateLoggingSettings(body: UpdateLoggingSettingsRequest): Promise<LoggingSettings> {
return request<LoggingSettings>('/api/settings/logging', { body, method: 'PUT' });
}
export function getUiSettings(): Promise<UiSettings> {
return request<UiSettings>('/api/settings/ui');
}
export function updateUiSettings(body: UpdateUiSettingsRequest): Promise<UiSettings> {
return request<UiSettings>('/api/settings/ui', { body, method: 'PUT' });
}
export function getHdhrSettings(): Promise<HdhrSettings> {
return request<HdhrSettings>('/api/settings/hdhr');
}
export function updateHdhrSettings(body: UpdateHdhrSettingsRequest): Promise<HdhrSettings> {
return request<HdhrSettings>('/api/settings/hdhr', { body, method: 'PUT' });
}
export function getResolutions(): Promise<Resolution[]> {
return request<Resolution[]>('/api/settings/resolutions');
}
export function createResolution(body: CreateResolutionRequest): Promise<Resolution> {
return request<Resolution>('/api/settings/resolutions', { body, method: 'POST' });
}
export function deleteResolution(id: number): Promise<void> {
return request<void>(`/api/settings/resolutions/${id}`, { method: 'DELETE' });
}
export interface SettingsScreenData {
ffmpeg: FfmpegSettings;
playout: PlayoutSettings;
xmltv: XmltvSettings;
scanner: ScannerSettings;
logging: LoggingSettings;
ui: UiSettings;
hdhr: HdhrSettings;
resolutions: Resolution[];
ffmpegProfiles: FFmpegProfile[];
watermarks: Watermark[];
fillerPresets: FillerPreset[];
mediaSources: MediaSource[];
version: DashboardVersion | null;
health: HealthCheck[];
// Per-resource tier-2 failure flags: reference data that failed to load. The screen
// still renders (with empty/null fallbacks above); the affected card shows an inline
// "Couldn't load X" note instead of data.
referenceFailures: ReferenceFailures;
}
export interface ReferenceFailures {
ffmpegProfiles: boolean;
fillerPresets: boolean;
health: boolean;
mediaSources: boolean;
version: boolean;
watermarks: boolean;
}
export type SettingsScreenQueryState =
| {
data: SettingsScreenData;
error: string | null;
refresh: () => void;
refreshResolutions: () => Promise<void>;
status: 'success';
}
| { data: null; error: string; refresh: () => void; refreshResolutions: () => Promise<void>; status: 'error' }
| { data: null; error: null; refresh: () => void; refreshResolutions: () => Promise<void>; status: 'loading' };
type SettingsScreenState =
| { data: SettingsScreenData; error: string | null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
// A settled tier-2 (reference-data) result: falls back to `fallback` on failure and
// records the failure so the UI can show an inline note instead of data.
function settled<T>(result: PromiseSettledResult<T>, fallback: T): { failed: boolean; value: T } {
return result.status === 'fulfilled' ? { failed: false, value: result.value } : { failed: true, value: fallback };
}
async function loadSettingsScreenData(): Promise<SettingsScreenData> {
// Tier 1 (critical): the settings groups themselves + resolutions. A failure here
// rejects and produces the screen-level error state (with retry).
const tier1 = Promise.all([
getFfmpegSettings(),
getPlayoutSettings(),
getXmltvSettings(),
getScannerSettings(),
getLoggingSettings(),
getUiSettings(),
getHdhrSettings(),
getResolutions()
]);
// Tier 2 (reference data): each settles independently. A failure never blocks the
// screen - the affected card degrades to an inline "Couldn't load X" note.
const tier2 = Promise.allSettled([
getFFmpegProfiles(),
getWatermarks(),
getFillerPresets(),
getMediaSources(),
getDashboardVersion(),
getDashboardHealth()
] as const);
const [ffmpeg, playout, xmltv, scanner, logging, ui, hdhr, resolutions] = await tier1;
const [profilesResult, watermarksResult, fillerPresetsResult, mediaSourcesResult, versionResult, healthResult] =
await tier2;
const ffmpegProfiles = settled<FFmpegProfile[]>(profilesResult, []);
const watermarks = settled<Watermark[]>(watermarksResult, []);
const fillerPresets = settled<FillerPreset[]>(fillerPresetsResult, []);
const mediaSources = settled<MediaSource[]>(mediaSourcesResult, []);
const version = settled<DashboardVersion | null>(versionResult, null);
const health = settled<HealthCheck[]>(healthResult, []);
return {
ffmpeg,
ffmpegProfiles: ffmpegProfiles.value,
fillerPresets: fillerPresets.value,
health: health.value,
hdhr,
logging,
mediaSources: mediaSources.value,
playout,
referenceFailures: {
ffmpegProfiles: ffmpegProfiles.failed,
fillerPresets: fillerPresets.failed,
health: health.failed,
mediaSources: mediaSources.failed,
version: version.failed,
watermarks: watermarks.failed
},
resolutions,
scanner,
ui,
version: version.value,
watermarks: watermarks.value,
xmltv
};
}
export function useSettingsScreenQuery(): SettingsScreenQueryState {
const [state, setState] = useState<SettingsScreenState>({ data: null, error: null, status: 'loading' });
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback(() => {
loadSettingsScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromSettingsError(error, 'Unable to load settings'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const refreshResolutions = useCallback(async () => {
const resolutions = await getResolutions();
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return { data: { ...current.data, resolutions }, error: current.error, status: 'success' };
});
}, []);
if (state.status === 'success') {
return { data: state.data, error: state.error, refresh, refreshResolutions, status: 'success' };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, refreshResolutions, status: 'error' };
}
return { data: null, error: null, refresh, refreshResolutions, status: 'loading' };
}
export function messageFromSettingsError(error: unknown, fallback = 'Unable to load settings'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
File diff suppressed because it is too large Load Diff
+319
View File
@@ -3108,4 +3108,323 @@
.ctv-builder-scroll {
overflow: visible;
}
.ctv-settings {
height: auto;
flex-direction: column;
margin: calc(var(--space-7, 16px) * -1) calc(var(--pad-page, 32px) * -1) calc(var(--pad-page, 32px) * -1);
}
.ctv-settings-rail {
width: auto;
border-right: none;
border-bottom: 1px solid var(--border-hairline);
}
}
/* ---- Settings (#93) ------------------------------------------------------ */
/* Full-height flagship screen, same pattern as .ctv-builder: break out of
.ctv-main's page padding to get an independently-scrolling rail + pane. */
@keyframes ctv-fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ctv-fade-in {
animation: ctv-fade-in 240ms var(--ease-out, cubic-bezier(0.16, 1, 0.3, 1));
}
.ctv-settings {
display: flex;
height: calc(100vh - 132px);
min-height: 460px;
margin: calc(var(--space-7, 16px) * -1) calc(var(--pad-page, 32px) * -1) calc(var(--pad-page, 32px) * -1);
border-top: 1px solid var(--border-hairline);
}
.ctv-settings-rail {
display: flex;
flex-direction: column;
width: 224px;
flex: 0 0 auto;
padding: var(--space-6, 12px) var(--space-4, 8px);
overflow: auto;
border-right: 1px solid var(--border-hairline);
}
.ctv-settings-rail-item {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
width: 100%;
padding: 9px 10px;
margin-bottom: 2px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
text-align: left;
}
.ctv-settings-rail-item:hover {
background: var(--ctv-surface-2);
}
.ctv-settings-rail-item-active,
.ctv-settings-rail-item-active:hover {
background: var(--ctv-accent-soft);
}
.ctv-settings-rail-icon {
display: inline-flex;
flex: 0 0 auto;
}
.ctv-settings-rail-text {
flex: 1;
min-width: 0;
}
.ctv-settings-rail-label {
font: var(--weight-medium) var(--text-sm) / 1 var(--font-sans);
}
.ctv-settings-rail-hint {
margin-top: 3px;
font: var(--text-2xs) / 1 var(--font-sans);
color: var(--text-disabled);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.ctv-settings-body {
flex: 1;
min-width: 0;
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
}
.ctv-settings-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--space-8, 20px) var(--space-9, 24px) 96px;
}
.ctv-settings-loading,
.ctv-settings-error {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
padding: var(--space-9, 24px);
color: var(--text-secondary);
}
.ctv-settings-error {
color: var(--status-error);
}
.ctv-settings-pane {
max-width: 760px;
display: flex;
flex-direction: column;
gap: var(--space-7, 16px);
animation: ctv-fade-in 240ms var(--ease-out, cubic-bezier(0.16, 1, 0.3, 1));
}
.ctv-settings-pane-title {
font: var(--weight-semibold) var(--text-lg, 15px) / 1.2 var(--font-sans);
color: var(--text-primary);
}
.ctv-settings-pane-subtitle {
margin-top: 5px;
font: var(--text-sm) / 1.45 var(--font-sans);
color: var(--text-secondary);
}
.ctv-settings-row {
display: flex;
align-items: center;
gap: var(--space-8, 20px);
padding: 13px var(--space-7, 16px);
border-top: 1px solid var(--border-hairline);
}
.ctv-settings-row-main {
flex: 1;
min-width: 0;
}
.ctv-settings-row-label {
font: var(--weight-medium) var(--text-sm) / 1.3 var(--font-sans);
color: var(--text-primary);
}
.ctv-settings-row-help {
margin-top: 3px;
font: var(--text-xs) / 1.45 var(--font-sans);
color: var(--text-disabled);
}
.ctv-settings-row-control {
display: flex;
justify-content: flex-end;
}
.ctv-settings-unit {
font: var(--text-2xs) / 1 var(--font-sans);
color: var(--text-disabled);
}
.ctv-settings-callout {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
padding: 10px 14px;
border-radius: var(--radius-sm);
background: var(--ctv-surface-2);
border: 1px dashed var(--border-control);
color: var(--text-disabled);
}
.ctv-settings-callout span {
flex: 1;
font: var(--text-xs) / 1.4 var(--font-sans);
color: var(--text-secondary);
}
.ctv-settings-warn-callout {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
padding: 10px 14px;
border-radius: var(--radius-sm);
background: var(--ctv-warn-soft);
border: 1px solid transparent;
font: var(--text-xs) / 1.4 var(--font-sans);
color: var(--text-secondary);
}
.ctv-settings-flush-row {
display: flex;
align-items: center;
gap: var(--space-6, 12px);
padding: 10px var(--space-7, 16px);
border-top: 1px solid var(--border-hairline);
}
.ctv-settings-flush-row-main {
flex: 1;
min-width: 0;
font: var(--text-sm) / 1 var(--font-sans);
color: var(--text-primary);
}
.ctv-settings-flush-row-title {
font-weight: var(--weight-medium);
}
.ctv-settings-flush-row-detail {
margin-left: var(--space-4, 8px);
font: var(--text-2xs) / 1 var(--font-mono);
color: var(--text-disabled);
}
.ctv-settings-flush-footer {
display: flex;
align-items: center;
gap: var(--space-4, 8px);
padding: 10px var(--space-7, 16px);
border-top: 1px solid var(--border-hairline);
background: var(--ctv-bg-sunken);
}
.ctv-settings-uuid-row {
display: flex;
align-items: center;
gap: var(--space-3, 6px);
width: 100%;
justify-content: flex-end;
color: var(--text-secondary);
font: var(--text-xs) / 1 var(--font-sans);
overflow: hidden;
}
.ctv-settings-uuid-row span {
overflow: hidden;
text-overflow: ellipsis;
}
.ctv-settings-savebar-wrap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
padding: 0 var(--space-9, 24px) 18px;
pointer-events: none;
}
.ctv-settings-savebar {
pointer-events: auto;
display: flex;
align-items: center;
gap: var(--space-6, 12px);
padding: 10px 12px 10px 16px;
border-radius: var(--radius-pill);
background: var(--surface-raised);
border: 1px solid var(--border-control);
box-shadow: var(--shadow-pop);
animation: ctv-fade-in 200ms var(--ease-out, cubic-bezier(0.16, 1, 0.3, 1));
}
.ctv-settings-savebar-text {
font: var(--weight-medium) var(--text-sm) / 1 var(--font-sans);
color: var(--text-primary);
}
.ctv-settings-savebar-error {
font: var(--text-xs) / 1 var(--font-sans);
color: var(--status-error);
}
.ctv-settings-savebar-divider {
width: 1px;
height: 18px;
background: var(--border-hairline);
}
/* inline degraded-state note for reference data that failed to load */
.ctv-settings-ref-failure {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
font: var(--text-xs) / 1.3 var(--font-sans);
color: var(--text-secondary);
}
.ctv-settings-ref-failure svg {
color: var(--status-warn);
flex: 0 0 auto;
}
@media (prefers-reduced-motion: reduce) {
.ctv-fade-in,
.ctv-settings-pane,
.ctv-settings-savebar {
animation: none;
}
}