Explorar el Código

Merge pull request #6436 from daullmer/splashscreen

Cody Robibero hace 3 años
padre
commit
e5701c396a

+ 6 - 0
Emby.Drawing/NullImageEncoder.cs

@@ -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)
         {

+ 79 - 0
Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs

@@ -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 }
+        });
+    }
+}

+ 2 - 2
Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs

@@ -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;

+ 166 - 65
Jellyfin.Api/Controllers/ImageController.cs

@@ -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);

+ 8 - 0
Jellyfin.Drawing.Skia/SkiaEncoder.cs

@@ -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

+ 37 - 0
Jellyfin.Drawing.Skia/SkiaHelper.cs

@@ -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;
+        }
     }
 }

+ 148 - 0
Jellyfin.Drawing.Skia/SplashscreenBuilder.cs

@@ -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;
+        }
+    }
+}

+ 2 - 30
Jellyfin.Drawing.Skia/StripCollageBuilder.cs

@@ -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)

+ 7 - 0
MediaBrowser.Controller/Drawing/IImageEncoder.cs

@@ -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);
     }
 }

+ 28 - 15
MediaBrowser.Model/Branding/BrandingOptions.cs

@@ -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; }
 }