Merge pull request #6436 from daullmer/splashscreen

This commit is contained in:
Cody Robibero 2022-01-28 08:12:21 -07:00 committed by GitHub
commit e5701c396a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 483 additions and 112 deletions

View File

@ -43,6 +43,12 @@ namespace Emby.Drawing
throw new NotImplementedException();
}
/// <inheritdoc />
public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public string GetImageBlurHash(int xComp, int yComp, string path)
{

View File

@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library;
/// <summary>
/// The splashscreen post scan task.
/// </summary>
public class SplashscreenPostScanTask : ILibraryPostScanTask
{
private readonly IItemRepository _itemRepository;
private readonly IImageEncoder _imageEncoder;
private readonly ILogger<SplashscreenPostScanTask> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SplashscreenPostScanTask"/> class.
/// </summary>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
/// <param name="imageEncoder">Instance of the <see cref="IImageEncoder"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{SplashscreenPostScanTask}"/> interface.</param>
public SplashscreenPostScanTask(
IItemRepository itemRepository,
IImageEncoder imageEncoder,
ILogger<SplashscreenPostScanTask> logger)
{
_itemRepository = itemRepository;
_imageEncoder = imageEncoder;
_logger = logger;
}
/// <inheritdoc />
public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
{
var posters = GetItemsWithImageType(ImageType.Primary).Select(x => x.GetImages(ImageType.Primary).First().Path).ToList();
var backdrops = GetItemsWithImageType(ImageType.Thumb).Select(x => x.GetImages(ImageType.Thumb).First().Path).ToList();
if (backdrops.Count == 0)
{
// Thumb images fit better because they include the title in the image but are not provided with TMDb.
// Using backdrops as a fallback to generate an image at all
_logger.LogDebug("No thumb images found. Using backdrops to generate splashscreen");
backdrops = GetItemsWithImageType(ImageType.Backdrop).Select(x => x.GetImages(ImageType.Backdrop).First().Path).ToList();
}
_imageEncoder.CreateSplashscreen(posters, backdrops);
return Task.CompletedTask;
}
private IReadOnlyList<BaseItem> GetItemsWithImageType(ImageType imageType)
{
// TODO make included libraries configurable
return _itemRepository.GetItemList(new InternalItemsQuery
{
CollapseBoxSetItems = false,
Recursive = true,
DtoOptions = new DtoOptions(false),
ImageTypes = new[] { imageType },
Limit = 30,
// TODO max parental rating configurable
MaxParentalRating = 10,
OrderBy = new[]
{
(ItemSortBy.Random, SortOrder.Ascending)
},
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Series }
});
}
}

View File

@ -25,8 +25,8 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
/// <summary>
/// Initializes a new instance of the <see cref="RefreshMediaLibraryTask" /> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="localization">The localization manager.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
public RefreshMediaLibraryTask(ILibraryManager libraryManager, ILocalizationManager localization)
{
_libraryManager = libraryManager;

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@ -11,12 +12,14 @@ using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Branding;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
@ -44,6 +47,8 @@ namespace Jellyfin.Api.Controllers
private readonly IAuthorizationContext _authContext;
private readonly ILogger<ImageController> _logger;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IApplicationPaths _appPaths;
private readonly IImageEncoder _imageEncoder;
/// <summary>
/// Initializes a new instance of the <see cref="ImageController"/> class.
@ -56,6 +61,8 @@ namespace Jellyfin.Api.Controllers
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{ImageController}"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="imageEncoder">Instance of the <see cref="IImageEncoder"/> interface.</param>
public ImageController(
IUserManager userManager,
ILibraryManager libraryManager,
@ -64,7 +71,9 @@ namespace Jellyfin.Api.Controllers
IFileSystem fileSystem,
IAuthorizationContext authContext,
ILogger<ImageController> logger,
IServerConfigurationManager serverConfigurationManager)
IServerConfigurationManager serverConfigurationManager,
IApplicationPaths appPaths,
IImageEncoder imageEncoder)
{
_userManager = userManager;
_libraryManager = libraryManager;
@ -74,6 +83,8 @@ namespace Jellyfin.Api.Controllers
_authContext = authContext;
_logger = logger;
_serverConfigurationManager = serverConfigurationManager;
_appPaths = appPaths;
_imageEncoder = imageEncoder;
}
/// <summary>
@ -1692,6 +1703,130 @@ namespace Jellyfin.Api.Controllers
.ConfigureAwait(false);
}
/// <summary>
/// Generates or gets the splashscreen.
/// </summary>
/// <param name="tag">Supply the cache tag from the item object to receive strong caching headers.</param>
/// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
/// <param name="maxWidth">The maximum image width to return.</param>
/// <param name="maxHeight">The maximum image height to return.</param>
/// <param name="width">The fixed image width to return.</param>
/// <param name="height">The fixed image height to return.</param>
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
/// <param name="blur">Blur image.</param>
/// <param name="backgroundColor">Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Apply a foreground layer on top of the image.</param>
/// <param name="quality">Quality setting, from 0-100.</param>
/// <response code="200">Splashscreen returned successfully.</response>
/// <returns>The splashscreen.</returns>
[HttpGet("Branding/Splashscreen")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesImageFile]
public async Task<ActionResult> GetSplashscreen(
[FromQuery] string? tag,
[FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
[FromQuery, Range(0, 100)] int quality = 90)
{
var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
string splashscreenPath;
if (!string.IsNullOrWhiteSpace(brandingOptions.SplashscreenLocation)
&& System.IO.File.Exists(brandingOptions.SplashscreenLocation))
{
splashscreenPath = brandingOptions.SplashscreenLocation;
}
else
{
splashscreenPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
if (!System.IO.File.Exists(splashscreenPath))
{
return NotFound();
}
}
var outputFormats = GetOutputFormats(format);
TimeSpan? cacheDuration = null;
if (!string.IsNullOrEmpty(tag))
{
cacheDuration = TimeSpan.FromDays(365);
}
var options = new ImageProcessingOptions
{
Image = new ItemImageInfo
{
Path = splashscreenPath
},
Height = height,
MaxHeight = maxHeight,
MaxWidth = maxWidth,
FillHeight = fillHeight,
FillWidth = fillWidth,
Quality = quality,
Width = width,
Blur = blur,
BackgroundColor = backgroundColor,
ForegroundLayer = foregroundLayer,
SupportedOutputFormats = outputFormats
};
return await GetImageResult(
options,
cacheDuration,
ImmutableDictionary<string, string>.Empty,
Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
.ConfigureAwait(false);
}
/// <summary>
/// Uploads a custom splashscreen.
/// </summary>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
/// <response code="204">Successfully uploaded new splashscreen.</response>
/// <response code="400">Error reading MimeType from uploaded image.</response>
/// <response code="403">User does not have permission to upload splashscreen..</response>
/// <exception cref="ArgumentException">Error reading the image format.</exception>
[HttpPost("Branding/Splashscreen")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[AcceptsImageFile]
public async Task<ActionResult> UploadCustomSplashscreen()
{
await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType;
if (!mimeType.HasValue)
{
return BadRequest("Error reading mimetype from uploaded image");
}
var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + MimeTypes.ToExtension(mimeType.Value));
var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
brandingOptions.SplashscreenLocation = filePath;
_serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
await using (var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous))
{
await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false);
}
return NoContent();
}
private static async Task<MemoryStream> GetMemoryStream(Stream inputStream)
{
using var reader = new StreamReader(inputStream);
@ -1823,25 +1958,35 @@ namespace Jellyfin.Api.Controllers
{ "realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*" }
};
if (!imageInfo.IsLocalFile && item != null)
{
imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, imageIndex ?? 0).ConfigureAwait(false);
}
var options = new ImageProcessingOptions
{
Height = height,
ImageIndex = imageIndex ?? 0,
Image = imageInfo,
Item = item,
ItemId = itemId,
MaxHeight = maxHeight,
MaxWidth = maxWidth,
FillHeight = fillHeight,
FillWidth = fillWidth,
Quality = quality ?? 100,
Width = width,
AddPlayedIndicator = addPlayedIndicator ?? false,
PercentPlayed = percentPlayed ?? 0,
UnplayedCount = unplayedCount,
Blur = blur,
BackgroundColor = backgroundColor,
ForegroundLayer = foregroundLayer,
SupportedOutputFormats = outputFormats
};
return await GetImageResult(
item,
itemId,
imageIndex,
width,
height,
maxWidth,
maxHeight,
fillWidth,
fillHeight,
quality,
addPlayedIndicator,
percentPlayed,
unplayedCount,
blur,
backgroundColor,
foregroundLayer,
imageInfo,
outputFormats,
options,
cacheDuration,
responseHeaders,
isHeadRequest).ConfigureAwait(false);
@ -1921,56 +2066,12 @@ namespace Jellyfin.Api.Controllers
}
private async Task<ActionResult> GetImageResult(
BaseItem? item,
Guid itemId,
int? index,
int? width,
int? height,
int? maxWidth,
int? maxHeight,
int? fillWidth,
int? fillHeight,
int? quality,
bool? addPlayedIndicator,
double? percentPlayed,
int? unplayedCount,
int? blur,
string? backgroundColor,
string? foregroundLayer,
ItemImageInfo imageInfo,
IReadOnlyCollection<ImageFormat> supportedFormats,
ImageProcessingOptions imageProcessingOptions,
TimeSpan? cacheDuration,
IDictionary<string, string> headers,
bool isHeadRequest)
{
if (!imageInfo.IsLocalFile && item != null)
{
imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, index ?? 0).ConfigureAwait(false);
}
var options = new ImageProcessingOptions
{
Height = height,
ImageIndex = index ?? 0,
Image = imageInfo,
Item = item,
ItemId = itemId,
MaxHeight = maxHeight,
MaxWidth = maxWidth,
FillHeight = fillHeight,
FillWidth = fillWidth,
Quality = quality ?? 100,
Width = width,
AddPlayedIndicator = addPlayedIndicator ?? false,
PercentPlayed = percentPlayed ?? 0,
UnplayedCount = unplayedCount,
Blur = blur,
BackgroundColor = backgroundColor,
ForegroundLayer = foregroundLayer,
SupportedOutputFormats = supportedFormats
};
var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(options).ConfigureAwait(false);
var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(imageProcessingOptions).ConfigureAwait(false);
var disableCaching = Request.Headers[HeaderNames.CacheControl].Contains("no-cache");
var parsingSuccessful = DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader);

View File

@ -492,6 +492,14 @@ namespace Jellyfin.Drawing.Skia
}
}
/// <inheritdoc />
public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
{
var splashBuilder = new SplashscreenBuilder(this);
var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
splashBuilder.GenerateSplash(posters, backdrops, outputPath);
}
private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
{
try

View File

@ -1,3 +1,4 @@
using System.Collections.Generic;
using SkiaSharp;
namespace Jellyfin.Drawing.Skia
@ -19,5 +20,41 @@ namespace Jellyfin.Drawing.Skia
throw new SkiaCodecException(result);
}
}
/// <summary>
/// Gets the next valid image as a bitmap.
/// </summary>
/// <param name="skiaEncoder">The current skia encoder.</param>
/// <param name="paths">The list of image paths.</param>
/// <param name="currentIndex">The current checked indes.</param>
/// <param name="newIndex">The new index.</param>
/// <returns>A valid bitmap, or null if no bitmap exists after <c>currentIndex</c>.</returns>
public static SKBitmap? GetNextValidImage(SkiaEncoder skiaEncoder, IReadOnlyList<string> paths, int currentIndex, out int newIndex)
{
var imagesTested = new Dictionary<int, int>();
SKBitmap? bitmap = null;
while (imagesTested.Count < paths.Count)
{
if (currentIndex >= paths.Count)
{
currentIndex = 0;
}
bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _);
imagesTested[currentIndex] = 0;
currentIndex++;
if (bitmap != null)
{
break;
}
}
newIndex = currentIndex;
return bitmap;
}
}
}

View File

@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using SkiaSharp;
namespace Jellyfin.Drawing.Skia
{
/// <summary>
/// Used to build the splashscreen.
/// </summary>
public class SplashscreenBuilder
{
private const int FinalWidth = 1920;
private const int FinalHeight = 1080;
// generated collage resolution should be higher than the final resolution
private const int WallWidth = FinalWidth * 3;
private const int WallHeight = FinalHeight * 2;
private const int Rows = 6;
private const int Spacing = 20;
private readonly SkiaEncoder _skiaEncoder;
/// <summary>
/// Initializes a new instance of the <see cref="SplashscreenBuilder"/> class.
/// </summary>
/// <param name="skiaEncoder">The SkiaEncoder.</param>
public SplashscreenBuilder(SkiaEncoder skiaEncoder)
{
_skiaEncoder = skiaEncoder;
}
/// <summary>
/// Generate a splashscreen.
/// </summary>
/// <param name="posters">The poster paths.</param>
/// <param name="backdrops">The landscape paths.</param>
/// <param name="outputPath">The output path.</param>
public void GenerateSplash(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops, string outputPath)
{
using var wall = GenerateCollage(posters, backdrops);
using var transformed = Transform3D(wall);
using var outputStream = new SKFileWStream(outputPath);
using var pixmap = new SKPixmap(new SKImageInfo(FinalWidth, FinalHeight), transformed.GetPixels());
pixmap.Encode(outputStream, StripCollageBuilder.GetEncodedFormat(outputPath), 90);
}
/// <summary>
/// Generates a collage of posters and landscape pictures.
/// </summary>
/// <param name="posters">The poster paths.</param>
/// <param name="backdrops">The landscape paths.</param>
/// <returns>The created collage as a bitmap.</returns>
private SKBitmap GenerateCollage(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
{
var posterIndex = 0;
var backdropIndex = 0;
var bitmap = new SKBitmap(WallWidth, WallHeight);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Black);
int posterHeight = WallHeight / 6;
for (int i = 0; i < Rows; i++)
{
int imageCounter = Random.Shared.Next(0, 5);
int currentWidthPos = i * 75;
int currentHeight = i * (posterHeight + Spacing);
while (currentWidthPos < WallWidth)
{
SKBitmap? currentImage;
switch (imageCounter)
{
case 0:
case 2:
case 3:
currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, posters, posterIndex, out int newPosterIndex);
posterIndex = newPosterIndex;
break;
default:
currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, backdrops, backdropIndex, out int newBackdropIndex);
backdropIndex = newBackdropIndex;
break;
}
if (currentImage == null)
{
throw new ArgumentException("Not enough valid pictures provided to create a splashscreen!");
}
// resize to the same aspect as the original
var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height);
using var resizedBitmap = new SKBitmap(imageWidth, posterHeight);
currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High);
// draw on canvas
canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight);
currentWidthPos += imageWidth + Spacing;
currentImage.Dispose();
if (imageCounter >= 4)
{
imageCounter = 0;
}
else
{
imageCounter++;
}
}
}
return bitmap;
}
/// <summary>
/// Transform the collage in 3D space.
/// </summary>
/// <param name="input">The bitmap to transform.</param>
/// <returns>The transformed image.</returns>
private SKBitmap Transform3D(SKBitmap input)
{
var bitmap = new SKBitmap(FinalWidth, FinalHeight);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Black);
var matrix = new SKMatrix
{
ScaleX = 0.324108899f,
ScaleY = 0.563934922f,
SkewX = -0.244337708f,
SkewY = 0.0377609022f,
TransX = 42.0407715f,
TransY = -198.104706f,
Persp0 = -9.08959337E-05f,
Persp1 = 6.85242048E-05f,
Persp2 = 0.988209724f
};
canvas.SetMatrix(matrix);
canvas.DrawBitmap(input, 0, 0);
return bitmap;
}
}
}

View File

@ -99,7 +99,7 @@ namespace Jellyfin.Drawing.Skia
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Black);
using var backdrop = GetNextValidImage(paths, 0, out _);
using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _);
if (backdrop == null)
{
return bitmap;
@ -152,34 +152,6 @@ namespace Jellyfin.Drawing.Skia
return bitmap;
}
private SKBitmap? GetNextValidImage(IReadOnlyList<string> paths, int currentIndex, out int newIndex)
{
var imagesTested = new Dictionary<int, int>();
SKBitmap? bitmap = null;
while (imagesTested.Count < paths.Count)
{
if (currentIndex >= paths.Count)
{
currentIndex = 0;
}
bitmap = _skiaEncoder.Decode(paths[currentIndex], false, null, out _);
imagesTested[currentIndex] = 0;
currentIndex++;
if (bitmap != null)
{
break;
}
}
newIndex = currentIndex;
return bitmap;
}
private SKBitmap BuildSquareCollageBitmap(IReadOnlyList<string> paths, int width, int height)
{
var bitmap = new SKBitmap(width, height);
@ -192,7 +164,7 @@ namespace Jellyfin.Drawing.Skia
{
for (var y = 0; y < 2; y++)
{
using var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex);
using var currentBitmap = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, imageIndex, out int newIndex);
imageIndex = newIndex;
if (currentBitmap == null)

View File

@ -74,5 +74,12 @@ namespace MediaBrowser.Controller.Drawing
/// <param name="options">The options to use when creating the collage.</param>
/// <param name="libraryName">Optional. </param>
void CreateImageCollage(ImageCollageOptions options, string? libraryName);
/// <summary>
/// Creates a new splashscreen image.
/// </summary>
/// <param name="posters">The list of poster paths.</param>
/// <param name="backdrops">The list of backdrop paths.</param>
void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops);
}
}

View File

@ -1,19 +1,32 @@
#pragma warning disable CS1591
using System.Text.Json.Serialization;
using System.Xml.Serialization;
namespace MediaBrowser.Model.Branding
namespace MediaBrowser.Model.Branding;
/// <summary>
/// The branding options.
/// </summary>
public class BrandingOptions
{
public class BrandingOptions
{
/// <summary>
/// Gets or sets the login disclaimer.
/// </summary>
/// <value>The login disclaimer.</value>
public string? LoginDisclaimer { get; set; }
/// <summary>
/// Gets or sets the login disclaimer.
/// </summary>
/// <value>The login disclaimer.</value>
public string? LoginDisclaimer { get; set; }
/// <summary>
/// Gets or sets the custom CSS.
/// </summary>
/// <value>The custom CSS.</value>
public string? CustomCss { get; set; }
}
/// <summary>
/// Gets or sets the custom CSS.
/// </summary>
/// <value>The custom CSS.</value>
public string? CustomCss { get; set; }
/// <summary>
/// Gets or sets the splashscreen location on disk.
/// </summary>
/// <remarks>
/// Not served via the API.
/// Only used to save the custom uploaded user splashscreen in the configuration file.
/// </remarks>
[JsonIgnore]
public string? SplashscreenLocation { get; set; }
}