Browse Source

Use file-scoped namespaces in Jellyfin.Drawing

Patrick Barron 2 years ago
parent
commit
6c7225b943
2 changed files with 495 additions and 497 deletions
  1. 449 450
      src/Jellyfin.Drawing/ImageProcessor.cs
  2. 46 47
      src/Jellyfin.Drawing/NullImageEncoder.cs

+ 449 - 450
src/Jellyfin.Drawing/ImageProcessor.cs

@@ -19,551 +19,550 @@ using MediaBrowser.Model.Net;
 using Microsoft.Extensions.Logging;
 using Photo = MediaBrowser.Controller.Entities.Photo;
 
-namespace Jellyfin.Drawing
+namespace Jellyfin.Drawing;
+
+/// <summary>
+/// Class ImageProcessor.
+/// </summary>
+public sealed class ImageProcessor : IImageProcessor, IDisposable
 {
+    // Increment this when there's a change requiring caches to be invalidated
+    private const char Version = '3';
+
+    private static readonly HashSet<string> _transparentImageTypes
+        = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
+
+    private readonly ILogger<ImageProcessor> _logger;
+    private readonly IFileSystem _fileSystem;
+    private readonly IServerApplicationPaths _appPaths;
+    private readonly IImageEncoder _imageEncoder;
+    private readonly IMediaEncoder _mediaEncoder;
+
+    private bool _disposed;
+
     /// <summary>
-    /// Class ImageProcessor.
+    /// Initializes a new instance of the <see cref="ImageProcessor"/> class.
     /// </summary>
-    public sealed class ImageProcessor : IImageProcessor, IDisposable
+    /// <param name="logger">The logger.</param>
+    /// <param name="appPaths">The server application paths.</param>
+    /// <param name="fileSystem">The filesystem.</param>
+    /// <param name="imageEncoder">The image encoder.</param>
+    /// <param name="mediaEncoder">The media encoder.</param>
+    public ImageProcessor(
+        ILogger<ImageProcessor> logger,
+        IServerApplicationPaths appPaths,
+        IFileSystem fileSystem,
+        IImageEncoder imageEncoder,
+        IMediaEncoder mediaEncoder)
     {
-        // Increment this when there's a change requiring caches to be invalidated
-        private const char Version = '3';
-
-        private static readonly HashSet<string> _transparentImageTypes
-            = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
-
-        private readonly ILogger<ImageProcessor> _logger;
-        private readonly IFileSystem _fileSystem;
-        private readonly IServerApplicationPaths _appPaths;
-        private readonly IImageEncoder _imageEncoder;
-        private readonly IMediaEncoder _mediaEncoder;
-
-        private bool _disposed;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ImageProcessor"/> class.
-        /// </summary>
-        /// <param name="logger">The logger.</param>
-        /// <param name="appPaths">The server application paths.</param>
-        /// <param name="fileSystem">The filesystem.</param>
-        /// <param name="imageEncoder">The image encoder.</param>
-        /// <param name="mediaEncoder">The media encoder.</param>
-        public ImageProcessor(
-            ILogger<ImageProcessor> logger,
-            IServerApplicationPaths appPaths,
-            IFileSystem fileSystem,
-            IImageEncoder imageEncoder,
-            IMediaEncoder mediaEncoder)
-        {
-            _logger = logger;
-            _fileSystem = fileSystem;
-            _imageEncoder = imageEncoder;
-            _mediaEncoder = mediaEncoder;
-            _appPaths = appPaths;
-        }
+        _logger = logger;
+        _fileSystem = fileSystem;
+        _imageEncoder = imageEncoder;
+        _mediaEncoder = mediaEncoder;
+        _appPaths = appPaths;
+    }
 
-        private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images");
+    private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images");
 
-        /// <inheritdoc />
-        public IReadOnlyCollection<string> SupportedInputFormats =>
-            new HashSet<string>(StringComparer.OrdinalIgnoreCase)
-            {
-                "tiff",
-                "tif",
-                "jpeg",
-                "jpg",
-                "png",
-                "aiff",
-                "cr2",
-                "crw",
-                "nef",
-                "orf",
-                "pef",
-                "arw",
-                "webp",
-                "gif",
-                "bmp",
-                "erf",
-                "raf",
-                "rw2",
-                "nrw",
-                "dng",
-                "ico",
-                "astc",
-                "ktx",
-                "pkm",
-                "wbmp"
-            };
-
-        /// <inheritdoc />
-        public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation;
-
-        /// <inheritdoc />
-        public async Task ProcessImage(ImageProcessingOptions options, Stream toStream)
-        {
-            var file = await ProcessImage(options).ConfigureAwait(false);
-            using (var fileStream = AsyncFile.OpenRead(file.Path))
-            {
-                await fileStream.CopyToAsync(toStream).ConfigureAwait(false);
-            }
+    /// <inheritdoc />
+    public IReadOnlyCollection<string> SupportedInputFormats =>
+        new HashSet<string>(StringComparer.OrdinalIgnoreCase)
+        {
+            "tiff",
+            "tif",
+            "jpeg",
+            "jpg",
+            "png",
+            "aiff",
+            "cr2",
+            "crw",
+            "nef",
+            "orf",
+            "pef",
+            "arw",
+            "webp",
+            "gif",
+            "bmp",
+            "erf",
+            "raf",
+            "rw2",
+            "nrw",
+            "dng",
+            "ico",
+            "astc",
+            "ktx",
+            "pkm",
+            "wbmp"
+        };
+
+    /// <inheritdoc />
+    public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation;
+
+    /// <inheritdoc />
+    public async Task ProcessImage(ImageProcessingOptions options, Stream toStream)
+    {
+        var file = await ProcessImage(options).ConfigureAwait(false);
+        using (var fileStream = AsyncFile.OpenRead(file.Path))
+        {
+            await fileStream.CopyToAsync(toStream).ConfigureAwait(false);
         }
+    }
 
-        /// <inheritdoc />
-        public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats()
-            => _imageEncoder.SupportedOutputFormats;
+    /// <inheritdoc />
+    public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats()
+        => _imageEncoder.SupportedOutputFormats;
 
-        /// <inheritdoc />
-        public bool SupportsTransparency(string path)
-            => _transparentImageTypes.Contains(Path.GetExtension(path));
+    /// <inheritdoc />
+    public bool SupportsTransparency(string path)
+        => _transparentImageTypes.Contains(Path.GetExtension(path));
 
-        /// <inheritdoc />
-        public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options)
-        {
-            ItemImageInfo originalImage = options.Image;
-            BaseItem item = options.Item;
+    /// <inheritdoc />
+    public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options)
+    {
+        ItemImageInfo originalImage = options.Image;
+        BaseItem item = options.Item;
 
-            string originalImagePath = originalImage.Path;
-            DateTime dateModified = originalImage.DateModified;
-            ImageDimensions? originalImageSize = null;
-            if (originalImage.Width > 0 && originalImage.Height > 0)
-            {
-                originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height);
-            }
+        string originalImagePath = originalImage.Path;
+        DateTime dateModified = originalImage.DateModified;
+        ImageDimensions? originalImageSize = null;
+        if (originalImage.Width > 0 && originalImage.Height > 0)
+        {
+            originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height);
+        }
 
-            var mimeType = MimeTypes.GetMimeType(originalImagePath);
-            if (!_imageEncoder.SupportsImageEncoding)
-            {
-                return (originalImagePath, mimeType, dateModified);
-            }
+        var mimeType = MimeTypes.GetMimeType(originalImagePath);
+        if (!_imageEncoder.SupportsImageEncoding)
+        {
+            return (originalImagePath, mimeType, dateModified);
+        }
 
-            var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false);
-            originalImagePath = supportedImageInfo.Path;
+        var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false);
+        originalImagePath = supportedImageInfo.Path;
 
-            // Original file doesn't exist, or original file is gif.
-            if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.OrdinalIgnoreCase))
-            {
-                return (originalImagePath, mimeType, dateModified);
-            }
+        // Original file doesn't exist, or original file is gif.
+        if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.OrdinalIgnoreCase))
+        {
+            return (originalImagePath, mimeType, dateModified);
+        }
 
-            dateModified = supportedImageInfo.DateModified;
-            bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath));
+        dateModified = supportedImageInfo.DateModified;
+        bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath));
 
-            bool autoOrient = false;
-            ImageOrientation? orientation = null;
-            if (item is Photo photo)
+        bool autoOrient = false;
+        ImageOrientation? orientation = null;
+        if (item is Photo photo)
+        {
+            if (photo.Orientation.HasValue)
             {
-                if (photo.Orientation.HasValue)
-                {
-                    if (photo.Orientation.Value != ImageOrientation.TopLeft)
-                    {
-                        autoOrient = true;
-                        orientation = photo.Orientation;
-                    }
-                }
-                else
+                if (photo.Orientation.Value != ImageOrientation.TopLeft)
                 {
-                    // Orientation unknown, so do it
                     autoOrient = true;
                     orientation = photo.Orientation;
                 }
             }
-
-            if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrientation))
+            else
             {
-                // Just spit out the original file if all the options are default
-                return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
-            }
-
-            int quality = options.Quality;
-
-            ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
-            string cacheFilePath = GetCacheFilePath(
-                originalImagePath,
-                options.Width,
-                options.Height,
-                options.MaxWidth,
-                options.MaxHeight,
-                options.FillWidth,
-                options.FillHeight,
-                quality,
-                dateModified,
-                outputFormat,
-                options.AddPlayedIndicator,
-                options.PercentPlayed,
-                options.UnplayedCount,
-                options.Blur,
-                options.BackgroundColor,
-                options.ForegroundLayer);
-
-            try
-            {
-                if (!File.Exists(cacheFilePath))
-                {
-                    string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat);
-
-                    if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
-                    {
-                        return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
-                    }
-                }
-
-                return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
-            }
-            catch (Exception ex)
-            {
-                // If it fails for whatever reason, return the original image
-                _logger.LogError(ex, "Error encoding image");
-                return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
+                // Orientation unknown, so do it
+                autoOrient = true;
+                orientation = photo.Orientation;
             }
         }
 
-        private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparency)
+        if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrientation))
         {
-            var serverFormats = GetSupportedImageOutputFormats();
-
-            // Client doesn't care about format, so start with webp if supported
-            if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp))
-            {
-                return ImageFormat.Webp;
-            }
+            // Just spit out the original file if all the options are default
+            return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
+        }
 
-            // If transparency is needed and webp isn't supported, than png is the only option
-            if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png))
+        int quality = options.Quality;
+
+        ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
+        string cacheFilePath = GetCacheFilePath(
+            originalImagePath,
+            options.Width,
+            options.Height,
+            options.MaxWidth,
+            options.MaxHeight,
+            options.FillWidth,
+            options.FillHeight,
+            quality,
+            dateModified,
+            outputFormat,
+            options.AddPlayedIndicator,
+            options.PercentPlayed,
+            options.UnplayedCount,
+            options.Blur,
+            options.BackgroundColor,
+            options.ForegroundLayer);
+
+        try
+        {
+            if (!File.Exists(cacheFilePath))
             {
-                return ImageFormat.Png;
-            }
+                string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat);
 
-            foreach (var format in clientSupportedFormats)
-            {
-                if (serverFormats.Contains(format))
+                if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
                 {
-                    return format;
+                    return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
                 }
             }
 
-            // We should never actually get here
-            return ImageFormat.Jpg;
+            return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
         }
+        catch (Exception ex)
+        {
+            // If it fails for whatever reason, return the original image
+            _logger.LogError(ex, "Error encoding image");
+            return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
+        }
+    }
 
-        private string GetMimeType(ImageFormat format, string path)
-            => format switch
-            {
-                ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"),
-                ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"),
-                ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"),
-                ImageFormat.Png => MimeTypes.GetMimeType("i.png"),
-                ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"),
-                _ => MimeTypes.GetMimeType(path)
-            };
-
-        /// <summary>
-        /// Gets the cache file path based on a set of parameters.
-        /// </summary>
-        private string GetCacheFilePath(
-            string originalPath,
-            int? width,
-            int? height,
-            int? maxWidth,
-            int? maxHeight,
-            int? fillWidth,
-            int? fillHeight,
-            int quality,
-            DateTime dateModified,
-            ImageFormat format,
-            bool addPlayedIndicator,
-            double percentPlayed,
-            int? unwatchedCount,
-            int? blur,
-            string backgroundColor,
-            string foregroundLayer)
-        {
-            var filename = new StringBuilder(256);
-            filename.Append(originalPath);
-
-            filename.Append(",quality=");
-            filename.Append(quality);
-
-            filename.Append(",datemodified=");
-            filename.Append(dateModified.Ticks);
-
-            filename.Append(",f=");
-            filename.Append(format);
-
-            if (width.HasValue)
-            {
-                filename.Append(",width=");
-                filename.Append(width.Value);
-            }
+    private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparency)
+    {
+        var serverFormats = GetSupportedImageOutputFormats();
 
-            if (height.HasValue)
-            {
-                filename.Append(",height=");
-                filename.Append(height.Value);
-            }
+        // Client doesn't care about format, so start with webp if supported
+        if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp))
+        {
+            return ImageFormat.Webp;
+        }
 
-            if (maxWidth.HasValue)
-            {
-                filename.Append(",maxwidth=");
-                filename.Append(maxWidth.Value);
-            }
+        // If transparency is needed and webp isn't supported, than png is the only option
+        if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png))
+        {
+            return ImageFormat.Png;
+        }
 
-            if (maxHeight.HasValue)
+        foreach (var format in clientSupportedFormats)
+        {
+            if (serverFormats.Contains(format))
             {
-                filename.Append(",maxheight=");
-                filename.Append(maxHeight.Value);
+                return format;
             }
+        }
 
-            if (fillWidth.HasValue)
-            {
-                filename.Append(",fillwidth=");
-                filename.Append(fillWidth.Value);
-            }
+        // We should never actually get here
+        return ImageFormat.Jpg;
+    }
 
-            if (fillHeight.HasValue)
-            {
-                filename.Append(",fillheight=");
-                filename.Append(fillHeight.Value);
-            }
+    private string GetMimeType(ImageFormat format, string path)
+        => format switch
+        {
+            ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"),
+            ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"),
+            ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"),
+            ImageFormat.Png => MimeTypes.GetMimeType("i.png"),
+            ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"),
+            _ => MimeTypes.GetMimeType(path)
+        };
 
-            if (addPlayedIndicator)
-            {
-                filename.Append(",pl=true");
-            }
+    /// <summary>
+    /// Gets the cache file path based on a set of parameters.
+    /// </summary>
+    private string GetCacheFilePath(
+        string originalPath,
+        int? width,
+        int? height,
+        int? maxWidth,
+        int? maxHeight,
+        int? fillWidth,
+        int? fillHeight,
+        int quality,
+        DateTime dateModified,
+        ImageFormat format,
+        bool addPlayedIndicator,
+        double percentPlayed,
+        int? unwatchedCount,
+        int? blur,
+        string backgroundColor,
+        string foregroundLayer)
+    {
+        var filename = new StringBuilder(256);
+        filename.Append(originalPath);
 
-            if (percentPlayed > 0)
-            {
-                filename.Append(",p=");
-                filename.Append(percentPlayed);
-            }
+        filename.Append(",quality=");
+        filename.Append(quality);
 
-            if (unwatchedCount.HasValue)
-            {
-                filename.Append(",p=");
-                filename.Append(unwatchedCount.Value);
-            }
+        filename.Append(",datemodified=");
+        filename.Append(dateModified.Ticks);
 
-            if (blur.HasValue)
-            {
-                filename.Append(",blur=");
-                filename.Append(blur.Value);
-            }
+        filename.Append(",f=");
+        filename.Append(format);
 
-            if (!string.IsNullOrEmpty(backgroundColor))
-            {
-                filename.Append(",b=");
-                filename.Append(backgroundColor);
-            }
+        if (width.HasValue)
+        {
+            filename.Append(",width=");
+            filename.Append(width.Value);
+        }
 
-            if (!string.IsNullOrEmpty(foregroundLayer))
-            {
-                filename.Append(",fl=");
-                filename.Append(foregroundLayer);
-            }
+        if (height.HasValue)
+        {
+            filename.Append(",height=");
+            filename.Append(height.Value);
+        }
 
-            filename.Append(",v=");
-            filename.Append(Version);
+        if (maxWidth.HasValue)
+        {
+            filename.Append(",maxwidth=");
+            filename.Append(maxWidth.Value);
+        }
 
-            return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant());
+        if (maxHeight.HasValue)
+        {
+            filename.Append(",maxheight=");
+            filename.Append(maxHeight.Value);
         }
 
-        /// <inheritdoc />
-        public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info)
+        if (fillWidth.HasValue)
         {
-            int width = info.Width;
-            int height = info.Height;
+            filename.Append(",fillwidth=");
+            filename.Append(fillWidth.Value);
+        }
 
-            if (height > 0 && width > 0)
-            {
-                return new ImageDimensions(width, height);
-            }
+        if (fillHeight.HasValue)
+        {
+            filename.Append(",fillheight=");
+            filename.Append(fillHeight.Value);
+        }
 
-            string path = info.Path;
-            _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path);
+        if (addPlayedIndicator)
+        {
+            filename.Append(",pl=true");
+        }
 
-            ImageDimensions size = GetImageDimensions(path);
-            info.Width = size.Width;
-            info.Height = size.Height;
+        if (percentPlayed > 0)
+        {
+            filename.Append(",p=");
+            filename.Append(percentPlayed);
+        }
 
-            return size;
+        if (unwatchedCount.HasValue)
+        {
+            filename.Append(",p=");
+            filename.Append(unwatchedCount.Value);
         }
 
-        /// <inheritdoc />
-        public ImageDimensions GetImageDimensions(string path)
-            => _imageEncoder.GetImageSize(path);
+        if (blur.HasValue)
+        {
+            filename.Append(",blur=");
+            filename.Append(blur.Value);
+        }
 
-        /// <inheritdoc />
-        public string GetImageBlurHash(string path)
+        if (!string.IsNullOrEmpty(backgroundColor))
         {
-            var size = GetImageDimensions(path);
-            return GetImageBlurHash(path, size);
+            filename.Append(",b=");
+            filename.Append(backgroundColor);
         }
 
-        /// <inheritdoc />
-        public string GetImageBlurHash(string path, ImageDimensions imageDimensions)
+        if (!string.IsNullOrEmpty(foregroundLayer))
         {
-            if (imageDimensions.Width <= 0 || imageDimensions.Height <= 0)
-            {
-                return string.Empty;
-            }
+            filename.Append(",fl=");
+            filename.Append(foregroundLayer);
+        }
+
+        filename.Append(",v=");
+        filename.Append(Version);
 
-            // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance.
-            // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / width.
-            // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components
-            float xCompF = MathF.Sqrt(16.0f * imageDimensions.Width / imageDimensions.Height);
-            float yCompF = xCompF * imageDimensions.Height / imageDimensions.Width;
+        return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant());
+    }
 
-            int xComp = Math.Min((int)xCompF + 1, 9);
-            int yComp = Math.Min((int)yCompF + 1, 9);
+    /// <inheritdoc />
+    public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info)
+    {
+        int width = info.Width;
+        int height = info.Height;
 
-            return _imageEncoder.GetImageBlurHash(xComp, yComp, path);
+        if (height > 0 && width > 0)
+        {
+            return new ImageDimensions(width, height);
         }
 
-        /// <inheritdoc />
-        public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
-            => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
+        string path = info.Path;
+        _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path);
+
+        ImageDimensions size = GetImageDimensions(path);
+        info.Width = size.Width;
+        info.Height = size.Height;
 
-        /// <inheritdoc />
-        public string GetImageCacheTag(BaseItem item, ChapterInfo chapter)
+        return size;
+    }
+
+    /// <inheritdoc />
+    public ImageDimensions GetImageDimensions(string path)
+        => _imageEncoder.GetImageSize(path);
+
+    /// <inheritdoc />
+    public string GetImageBlurHash(string path)
+    {
+        var size = GetImageDimensions(path);
+        return GetImageBlurHash(path, size);
+    }
+
+    /// <inheritdoc />
+    public string GetImageBlurHash(string path, ImageDimensions imageDimensions)
+    {
+        if (imageDimensions.Width <= 0 || imageDimensions.Height <= 0)
         {
-            return GetImageCacheTag(item, new ItemImageInfo
-            {
-                Path = chapter.ImagePath,
-                Type = ImageType.Chapter,
-                DateModified = chapter.ImageDateModified
-            });
+            return string.Empty;
         }
 
-        /// <inheritdoc />
-        public string? GetImageCacheTag(User user)
-        {
-            if (user.ProfileImage is null)
-            {
-                return null;
-            }
+        // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance.
+        // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / width.
+        // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components
+        float xCompF = MathF.Sqrt(16.0f * imageDimensions.Width / imageDimensions.Height);
+        float yCompF = xCompF * imageDimensions.Height / imageDimensions.Width;
 
-            return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5()
-                .ToString("N", CultureInfo.InvariantCulture);
-        }
+        int xComp = Math.Min((int)xCompF + 1, 9);
+        int yComp = Math.Min((int)yCompF + 1, 9);
+
+        return _imageEncoder.GetImageBlurHash(xComp, yComp, path);
+    }
+
+    /// <inheritdoc />
+    public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
+        => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
 
-        private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified)
+    /// <inheritdoc />
+    public string GetImageCacheTag(BaseItem item, ChapterInfo chapter)
+    {
+        return GetImageCacheTag(item, new ItemImageInfo
         {
-            var inputFormat = Path.GetExtension(originalImagePath.AsSpan()).TrimStart('.').ToString();
+            Path = chapter.ImagePath,
+            Type = ImageType.Chapter,
+            DateModified = chapter.ImageDateModified
+        });
+    }
 
-            // These are just jpg files renamed as tbn
-            if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase))
-            {
-                return Task.FromResult((originalImagePath, dateModified));
-            }
+    /// <inheritdoc />
+    public string? GetImageCacheTag(User user)
+    {
+        if (user.ProfileImage is null)
+        {
+            return null;
+        }
 
-            // TODO _mediaEncoder.ConvertImage is not implemented
-            // if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat))
-            // {
-            //     try
-            //     {
-            //         string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture);
-            //
-            //         string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png";
-            //         var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension);
-            //
-            //         var file = _fileSystem.GetFileInfo(outputPath);
-            //         if (!file.Exists)
-            //         {
-            //             await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false);
-            //             dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath);
-            //         }
-            //         else
-            //         {
-            //             dateModified = file.LastWriteTimeUtc;
-            //         }
-            //
-            //         originalImagePath = outputPath;
-            //     }
-            //     catch (Exception ex)
-            //     {
-            //         _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath);
-            //     }
-            // }
+        return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5()
+            .ToString("N", CultureInfo.InvariantCulture);
+    }
 
+    private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified)
+    {
+        var inputFormat = Path.GetExtension(originalImagePath.AsSpan()).TrimStart('.').ToString();
+
+        // These are just jpg files renamed as tbn
+        if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase))
+        {
             return Task.FromResult((originalImagePath, dateModified));
         }
 
-        /// <summary>
-        /// Gets the cache path.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <param name="uniqueName">Name of the unique.</param>
-        /// <param name="fileExtension">The file extension.</param>
-        /// <returns>System.String.</returns>
-        /// <exception cref="ArgumentNullException">
-        /// path
-        /// or
-        /// uniqueName
-        /// or
-        /// fileExtension.
-        /// </exception>
-        public string GetCachePath(string path, string uniqueName, string fileExtension)
-        {
-            ArgumentException.ThrowIfNullOrEmpty(path);
-            ArgumentException.ThrowIfNullOrEmpty(uniqueName);
-            ArgumentException.ThrowIfNullOrEmpty(fileExtension);
-
-            var filename = uniqueName.GetMD5() + fileExtension;
-
-            return GetCachePath(path, filename);
-        }
+        // TODO _mediaEncoder.ConvertImage is not implemented
+        // if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat))
+        // {
+        //     try
+        //     {
+        //         string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture);
+        //
+        //         string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png";
+        //         var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension);
+        //
+        //         var file = _fileSystem.GetFileInfo(outputPath);
+        //         if (!file.Exists)
+        //         {
+        //             await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false);
+        //             dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath);
+        //         }
+        //         else
+        //         {
+        //             dateModified = file.LastWriteTimeUtc;
+        //         }
+        //
+        //         originalImagePath = outputPath;
+        //     }
+        //     catch (Exception ex)
+        //     {
+        //         _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath);
+        //     }
+        // }
+
+        return Task.FromResult((originalImagePath, dateModified));
+    }
 
-        /// <summary>
-        /// Gets the cache path.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <param name="filename">The filename.</param>
-        /// <returns>System.String.</returns>
-        /// <exception cref="ArgumentNullException">
-        /// path
-        /// or
-        /// filename.
-        /// </exception>
-        public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename)
-        {
-            if (path.IsEmpty)
-            {
-                throw new ArgumentException("Path can't be empty.", nameof(path));
-            }
+    /// <summary>
+    /// Gets the cache path.
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <param name="uniqueName">Name of the unique.</param>
+    /// <param name="fileExtension">The file extension.</param>
+    /// <returns>System.String.</returns>
+    /// <exception cref="ArgumentNullException">
+    /// path
+    /// or
+    /// uniqueName
+    /// or
+    /// fileExtension.
+    /// </exception>
+    public string GetCachePath(string path, string uniqueName, string fileExtension)
+    {
+        ArgumentException.ThrowIfNullOrEmpty(path);
+        ArgumentException.ThrowIfNullOrEmpty(uniqueName);
+        ArgumentException.ThrowIfNullOrEmpty(fileExtension);
 
-            if (filename.IsEmpty)
-            {
-                throw new ArgumentException("Filename can't be empty.", nameof(filename));
-            }
+        var filename = uniqueName.GetMD5() + fileExtension;
 
-            var prefix = filename.Slice(0, 1);
+        return GetCachePath(path, filename);
+    }
 
-            return Path.Join(path, prefix, filename);
+    /// <summary>
+    /// Gets the cache path.
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <param name="filename">The filename.</param>
+    /// <returns>System.String.</returns>
+    /// <exception cref="ArgumentNullException">
+    /// path
+    /// or
+    /// filename.
+    /// </exception>
+    public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename)
+    {
+        if (path.IsEmpty)
+        {
+            throw new ArgumentException("Path can't be empty.", nameof(path));
         }
 
-        /// <inheritdoc />
-        public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
+        if (filename.IsEmpty)
         {
-            _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath);
+            throw new ArgumentException("Filename can't be empty.", nameof(filename));
+        }
 
-            _imageEncoder.CreateImageCollage(options, libraryName);
+        var prefix = filename.Slice(0, 1);
 
-            _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath);
-        }
+        return Path.Join(path, prefix, filename);
+    }
 
-        /// <inheritdoc />
-        public void Dispose()
-        {
-            if (_disposed)
-            {
-                return;
-            }
+    /// <inheritdoc />
+    public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
+    {
+        _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath);
 
-            if (_imageEncoder is IDisposable disposable)
-            {
-                disposable.Dispose();
-            }
+        _imageEncoder.CreateImageCollage(options, libraryName);
+
+        _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath);
+    }
+
+    /// <inheritdoc />
+    public void Dispose()
+    {
+        if (_disposed)
+        {
+            return;
+        }
 
-            _disposed = true;
+        if (_imageEncoder is IDisposable disposable)
+        {
+            disposable.Dispose();
         }
+
+        _disposed = true;
     }
 }

+ 46 - 47
src/Jellyfin.Drawing/NullImageEncoder.cs

@@ -3,56 +3,55 @@ using System.Collections.Generic;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Model.Drawing;
 
-namespace Jellyfin.Drawing
+namespace Jellyfin.Drawing;
+
+/// <summary>
+/// A fallback implementation of <see cref="IImageEncoder" />.
+/// </summary>
+public class NullImageEncoder : IImageEncoder
 {
-    /// <summary>
-    /// A fallback implementation of <see cref="IImageEncoder" />.
-    /// </summary>
-    public class NullImageEncoder : IImageEncoder
-    {
-        /// <inheritdoc />
-        public IReadOnlyCollection<string> SupportedInputFormats
-            => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "png", "jpeg", "jpg" };
+    /// <inheritdoc />
+    public IReadOnlyCollection<string> SupportedInputFormats
+        => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "png", "jpeg", "jpg" };
 
-        /// <inheritdoc />
-        public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
+    /// <inheritdoc />
+    public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
         => new HashSet<ImageFormat>() { ImageFormat.Jpg, ImageFormat.Png };
 
-        /// <inheritdoc />
-        public string Name => "Null Image Encoder";
-
-        /// <inheritdoc />
-        public bool SupportsImageCollageCreation => false;
-
-        /// <inheritdoc />
-        public bool SupportsImageEncoding => false;
-
-        /// <inheritdoc />
-        public ImageDimensions GetImageSize(string path)
-            => throw new NotImplementedException();
-
-        /// <inheritdoc />
-        public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
-        {
-            throw new NotImplementedException();
-        }
-
-        /// <inheritdoc />
-        public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
-        {
-            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)
-        {
-            throw new NotImplementedException();
-        }
+    /// <inheritdoc />
+    public string Name => "Null Image Encoder";
+
+    /// <inheritdoc />
+    public bool SupportsImageCollageCreation => false;
+
+    /// <inheritdoc />
+    public bool SupportsImageEncoding => false;
+
+    /// <inheritdoc />
+    public ImageDimensions GetImageSize(string path)
+        => throw new NotImplementedException();
+
+    /// <inheritdoc />
+    public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
+    {
+        throw new NotImplementedException();
+    }
+
+    /// <inheritdoc />
+    public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
+    {
+        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)
+    {
+        throw new NotImplementedException();
     }
 }