This commit was merged in pull request #138.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+106
@@ -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";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,4 +10,5 @@ export * from './libraryBrowse';
|
||||
export * from './pickers';
|
||||
export * from './playouts';
|
||||
export * from './schedules';
|
||||
export * from './settings';
|
||||
export * from './useChannelsQuery';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user