< Summary - Jellyfin

Information
Class: Jellyfin.Drawing.ImageProcessor
Assembly: Jellyfin.Drawing
File(s): /srv/git/jellyfin/src/Jellyfin.Drawing/ImageProcessor.cs
Line coverage
9%
Covered lines: 14
Uncovered lines: 141
Coverable lines: 155
Total lines: 531
Line coverage: 9%
Branch coverage
8%
Covered branches: 5
Total branches: 58
Branch coverage: 8.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

File(s)

/srv/git/jellyfin/src/Jellyfin.Drawing/ImageProcessor.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.IO;
 5using System.Linq;
 6using System.Net.Mime;
 7using System.Text;
 8using System.Threading;
 9using System.Threading.Tasks;
 10using AsyncKeyedLock;
 11using Jellyfin.Data.Entities;
 12using MediaBrowser.Common.Extensions;
 13using MediaBrowser.Controller;
 14using MediaBrowser.Controller.Configuration;
 15using MediaBrowser.Controller.Drawing;
 16using MediaBrowser.Controller.Entities;
 17using MediaBrowser.Model.Drawing;
 18using MediaBrowser.Model.Entities;
 19using MediaBrowser.Model.IO;
 20using MediaBrowser.Model.Net;
 21using Microsoft.Extensions.Logging;
 22using Photo = MediaBrowser.Controller.Entities.Photo;
 23
 24namespace Jellyfin.Drawing;
 25
 26/// <summary>
 27/// Class ImageProcessor.
 28/// </summary>
 29public sealed class ImageProcessor : IImageProcessor, IDisposable
 30{
 31    // Increment this when there's a change requiring caches to be invalidated
 32    private const char Version = '3';
 33
 034    private static readonly HashSet<string> _transparentImageTypes
 035        = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
 36
 37    private readonly ILogger<ImageProcessor> _logger;
 38    private readonly IFileSystem _fileSystem;
 39    private readonly IServerApplicationPaths _appPaths;
 40    private readonly IImageEncoder _imageEncoder;
 41
 42    private readonly AsyncNonKeyedLocker _parallelEncodingLimit;
 43
 44    private bool _disposed;
 45
 46    /// <summary>
 47    /// Initializes a new instance of the <see cref="ImageProcessor"/> class.
 48    /// </summary>
 49    /// <param name="logger">The logger.</param>
 50    /// <param name="appPaths">The server application paths.</param>
 51    /// <param name="fileSystem">The filesystem.</param>
 52    /// <param name="imageEncoder">The image encoder.</param>
 53    /// <param name="config">The configuration.</param>
 54    public ImageProcessor(
 55        ILogger<ImageProcessor> logger,
 56        IServerApplicationPaths appPaths,
 57        IFileSystem fileSystem,
 58        IImageEncoder imageEncoder,
 59        IServerConfigurationManager config)
 60    {
 2261        _logger = logger;
 2262        _fileSystem = fileSystem;
 2263        _imageEncoder = imageEncoder;
 2264        _appPaths = appPaths;
 65
 2266        var semaphoreCount = config.Configuration.ParallelImageEncodingLimit;
 2267        if (semaphoreCount < 1)
 68        {
 2269            semaphoreCount = 2 * Environment.ProcessorCount;
 70        }
 71
 2272        _parallelEncodingLimit = new(semaphoreCount);
 2273    }
 74
 075    private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images");
 76
 77    /// <inheritdoc />
 78    public IReadOnlyCollection<string> SupportedInputFormats =>
 079        new HashSet<string>(StringComparer.OrdinalIgnoreCase)
 080        {
 081            "tiff",
 082            "tif",
 083            "jpeg",
 084            "jpg",
 085            "png",
 086            "aiff",
 087            "cr2",
 088            "crw",
 089            "nef",
 090            "orf",
 091            "pef",
 092            "arw",
 093            "webp",
 094            "gif",
 095            "bmp",
 096            "erf",
 097            "raf",
 098            "rw2",
 099            "nrw",
 0100            "dng",
 0101            "ico",
 0102            "astc",
 0103            "ktx",
 0104            "pkm",
 0105            "wbmp",
 0106            "avif"
 0107        };
 108
 109    /// <inheritdoc />
 0110    public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation;
 111
 112    /// <inheritdoc />
 113    public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats()
 0114        => _imageEncoder.SupportedOutputFormats;
 115
 116    /// <inheritdoc />
 117    public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions option
 118    {
 119        ItemImageInfo originalImage = options.Image;
 120        BaseItem item = options.Item;
 121
 122        string originalImagePath = originalImage.Path;
 123        DateTime dateModified = originalImage.DateModified;
 124        ImageDimensions? originalImageSize = null;
 125        if (originalImage.Width > 0 && originalImage.Height > 0)
 126        {
 127            originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height);
 128        }
 129
 130        var mimeType = MimeTypes.GetMimeType(originalImagePath);
 131        if (!_imageEncoder.SupportsImageEncoding)
 132        {
 133            return (originalImagePath, mimeType, dateModified);
 134        }
 135
 136        var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false);
 137        originalImagePath = supportedImageInfo.Path;
 138
 139        // Original file doesn't exist, or original file is gif.
 140        if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.Ordina
 141        {
 142            return (originalImagePath, mimeType, dateModified);
 143        }
 144
 145        dateModified = supportedImageInfo.DateModified;
 146        bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath));
 147
 148        bool autoOrient = false;
 149        ImageOrientation? orientation = null;
 150        if (item is Photo photo)
 151        {
 152            if (photo.Orientation.HasValue)
 153            {
 154                if (photo.Orientation.Value != ImageOrientation.TopLeft)
 155                {
 156                    autoOrient = true;
 157                    orientation = photo.Orientation;
 158                }
 159            }
 160            else
 161            {
 162                // Orientation unknown, so do it
 163                autoOrient = true;
 164                orientation = photo.Orientation;
 165            }
 166        }
 167
 168        if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrie
 169        {
 170            // Just spit out the original file if all the options are default
 171            return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
 172        }
 173
 174        int quality = options.Quality;
 175
 176        ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
 177        string cacheFilePath = GetCacheFilePath(
 178            originalImagePath,
 179            options.Width,
 180            options.Height,
 181            options.MaxWidth,
 182            options.MaxHeight,
 183            options.FillWidth,
 184            options.FillHeight,
 185            quality,
 186            dateModified,
 187            outputFormat,
 188            options.PercentPlayed,
 189            options.UnplayedCount,
 190            options.Blur,
 191            options.BackgroundColor,
 192            options.ForegroundLayer);
 193
 194        try
 195        {
 196            if (!File.Exists(cacheFilePath))
 197            {
 198                string resultPath;
 199
 200                // Limit number of parallel (more precisely: concurrent) image encodings to prevent a high memory usage
 201                using (await _parallelEncodingLimit.LockAsync().ConfigureAwait(false))
 202                {
 203                    resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, o
 204                }
 205
 206                if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
 207                {
 208                    return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
 209                }
 210            }
 211
 212            return (cacheFilePath, outputFormat.GetMimeType(), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
 213        }
 214        catch (Exception ex)
 215        {
 216            // If it fails for whatever reason, return the original image
 217            _logger.LogError(ex, "Error encoding image");
 218            return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
 219        }
 220    }
 221
 222    private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparen
 223    {
 0224        var serverFormats = GetSupportedImageOutputFormats();
 225
 226        // Client doesn't care about format, so start with webp if supported
 0227        if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp))
 228        {
 0229            return ImageFormat.Webp;
 230        }
 231
 232        // If transparency is needed and webp isn't supported, than png is the only option
 0233        if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png))
 234        {
 0235            return ImageFormat.Png;
 236        }
 237
 0238        foreach (var format in clientSupportedFormats)
 239        {
 0240            if (serverFormats.Contains(format))
 241            {
 0242                return format;
 243            }
 244        }
 245
 246        // We should never actually get here
 0247        return ImageFormat.Jpg;
 0248    }
 249
 250    /// <summary>
 251    /// Gets the cache file path based on a set of parameters.
 252    /// </summary>
 253    private string GetCacheFilePath(
 254        string originalPath,
 255        int? width,
 256        int? height,
 257        int? maxWidth,
 258        int? maxHeight,
 259        int? fillWidth,
 260        int? fillHeight,
 261        int quality,
 262        DateTime dateModified,
 263        ImageFormat format,
 264        double percentPlayed,
 265        int? unwatchedCount,
 266        int? blur,
 267        string backgroundColor,
 268        string foregroundLayer)
 269    {
 0270        var filename = new StringBuilder(256);
 0271        filename.Append(originalPath);
 272
 0273        filename.Append(",quality=");
 0274        filename.Append(quality);
 275
 0276        filename.Append(",datemodified=");
 0277        filename.Append(dateModified.Ticks);
 278
 0279        filename.Append(",f=");
 0280        filename.Append(format);
 281
 0282        if (width.HasValue)
 283        {
 0284            filename.Append(",width=");
 0285            filename.Append(width.Value);
 286        }
 287
 0288        if (height.HasValue)
 289        {
 0290            filename.Append(",height=");
 0291            filename.Append(height.Value);
 292        }
 293
 0294        if (maxWidth.HasValue)
 295        {
 0296            filename.Append(",maxwidth=");
 0297            filename.Append(maxWidth.Value);
 298        }
 299
 0300        if (maxHeight.HasValue)
 301        {
 0302            filename.Append(",maxheight=");
 0303            filename.Append(maxHeight.Value);
 304        }
 305
 0306        if (fillWidth.HasValue)
 307        {
 0308            filename.Append(",fillwidth=");
 0309            filename.Append(fillWidth.Value);
 310        }
 311
 0312        if (fillHeight.HasValue)
 313        {
 0314            filename.Append(",fillheight=");
 0315            filename.Append(fillHeight.Value);
 316        }
 317
 0318        if (percentPlayed > 0)
 319        {
 0320            filename.Append(",p=");
 0321            filename.Append(percentPlayed);
 322        }
 323
 0324        if (unwatchedCount.HasValue)
 325        {
 0326            filename.Append(",p=");
 0327            filename.Append(unwatchedCount.Value);
 328        }
 329
 0330        if (blur.HasValue)
 331        {
 0332            filename.Append(",blur=");
 0333            filename.Append(blur.Value);
 334        }
 335
 0336        if (!string.IsNullOrEmpty(backgroundColor))
 337        {
 0338            filename.Append(",b=");
 0339            filename.Append(backgroundColor);
 340        }
 341
 0342        if (!string.IsNullOrEmpty(foregroundLayer))
 343        {
 0344            filename.Append(",fl=");
 0345            filename.Append(foregroundLayer);
 346        }
 347
 0348        filename.Append(",v=");
 0349        filename.Append(Version);
 350
 0351        return GetCachePath(ResizedImageCachePath, filename.ToString(), format.GetExtension());
 352    }
 353
 354    /// <inheritdoc />
 355    public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info)
 356    {
 0357        int width = info.Width;
 0358        int height = info.Height;
 359
 0360        if (height > 0 && width > 0)
 361        {
 0362            return new ImageDimensions(width, height);
 363        }
 364
 0365        string path = info.Path;
 0366        _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path);
 367
 0368        ImageDimensions size = GetImageDimensions(path);
 0369        info.Width = size.Width;
 0370        info.Height = size.Height;
 371
 0372        return size;
 373    }
 374
 375    /// <inheritdoc />
 376    public ImageDimensions GetImageDimensions(string path)
 0377        => _imageEncoder.GetImageSize(path);
 378
 379    /// <inheritdoc />
 380    public string GetImageBlurHash(string path)
 381    {
 0382        var size = GetImageDimensions(path);
 0383        return GetImageBlurHash(path, size);
 384    }
 385
 386    /// <inheritdoc />
 387    public string GetImageBlurHash(string path, ImageDimensions imageDimensions)
 388    {
 0389        if (imageDimensions.Width <= 0 || imageDimensions.Height <= 0)
 390        {
 0391            return string.Empty;
 392        }
 393
 394        // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance.
 395        // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / widt
 396        // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components
 0397        float xCompF = MathF.Sqrt(16.0f * imageDimensions.Width / imageDimensions.Height);
 0398        float yCompF = xCompF * imageDimensions.Height / imageDimensions.Width;
 399
 0400        int xComp = Math.Min((int)xCompF + 1, 9);
 0401        int yComp = Math.Min((int)yCompF + 1, 9);
 402
 0403        return _imageEncoder.GetImageBlurHash(xComp, yComp, path);
 404    }
 405
 406    /// <inheritdoc />
 407    public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
 0408        => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
 409
 410    /// <inheritdoc />
 411    public string? GetImageCacheTag(BaseItem item, ChapterInfo chapter)
 412    {
 0413        if (chapter.ImagePath is null)
 414        {
 0415            return null;
 416        }
 417
 0418        return GetImageCacheTag(item, new ItemImageInfo
 0419        {
 0420            Path = chapter.ImagePath,
 0421            Type = ImageType.Chapter,
 0422            DateModified = chapter.ImageDateModified
 0423        });
 424    }
 425
 426    /// <inheritdoc />
 427    public string? GetImageCacheTag(User user)
 428    {
 0429        if (user.ProfileImage is null)
 430        {
 0431            return null;
 432        }
 433
 0434        return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5()
 0435            .ToString("N", CultureInfo.InvariantCulture);
 436    }
 437
 438    private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified
 439    {
 0440        var inputFormat = Path.GetExtension(originalImagePath.AsSpan()).TrimStart('.').ToString();
 441
 442        // These are just jpg files renamed as tbn
 0443        if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase))
 444        {
 0445            return Task.FromResult((originalImagePath, dateModified));
 446        }
 447
 448        return Task.FromResult((originalImagePath, dateModified));
 449    }
 450
 451    /// <summary>
 452    /// Gets the cache path.
 453    /// </summary>
 454    /// <param name="path">The path.</param>
 455    /// <param name="uniqueName">Name of the unique.</param>
 456    /// <param name="fileExtension">The file extension.</param>
 457    /// <returns>System.String.</returns>
 458    /// <exception cref="ArgumentNullException">
 459    /// path
 460    /// or
 461    /// uniqueName
 462    /// or
 463    /// fileExtension.
 464    /// </exception>
 465    public string GetCachePath(string path, string uniqueName, string fileExtension)
 466    {
 0467        ArgumentException.ThrowIfNullOrEmpty(path);
 0468        ArgumentException.ThrowIfNullOrEmpty(uniqueName);
 0469        ArgumentException.ThrowIfNullOrEmpty(fileExtension);
 470
 0471        var filename = uniqueName.GetMD5() + fileExtension;
 472
 0473        return GetCachePath(path, filename);
 474    }
 475
 476    /// <summary>
 477    /// Gets the cache path.
 478    /// </summary>
 479    /// <param name="path">The path.</param>
 480    /// <param name="filename">The filename.</param>
 481    /// <returns>System.String.</returns>
 482    /// <exception cref="ArgumentNullException">
 483    /// path
 484    /// or
 485    /// filename.
 486    /// </exception>
 487    public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename)
 488    {
 0489        if (path.IsEmpty)
 490        {
 0491            throw new ArgumentException("Path can't be empty.", nameof(path));
 492        }
 493
 0494        if (filename.IsEmpty)
 495        {
 0496            throw new ArgumentException("Filename can't be empty.", nameof(filename));
 497        }
 498
 0499        var prefix = filename.Slice(0, 1);
 500
 0501        return Path.Join(path, prefix, filename);
 502    }
 503
 504    /// <inheritdoc />
 505    public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
 506    {
 0507        _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath);
 508
 0509        _imageEncoder.CreateImageCollage(options, libraryName);
 510
 0511        _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath);
 0512    }
 513
 514    /// <inheritdoc />
 515    public void Dispose()
 516    {
 22517        if (_disposed)
 518        {
 0519            return;
 520        }
 521
 22522        if (_imageEncoder is IDisposable disposable)
 523        {
 0524            disposable.Dispose();
 525        }
 526
 22527        _parallelEncodingLimit?.Dispose();
 528
 22529        _disposed = true;
 22530    }
 531}

Methods/Properties

.cctor()
.ctor(Microsoft.Extensions.Logging.ILogger`1<Jellyfin.Drawing.ImageProcessor>,MediaBrowser.Controller.IServerApplicationPaths,MediaBrowser.Model.IO.IFileSystem,MediaBrowser.Controller.Drawing.IImageEncoder,MediaBrowser.Controller.Configuration.IServerConfigurationManager)
get_ResizedImageCachePath()
get_SupportedInputFormats()
get_SupportsImageCollageCreation()
GetSupportedImageOutputFormats()
GetOutputFormat(System.Collections.Generic.IReadOnlyCollection`1<MediaBrowser.Model.Drawing.ImageFormat>,System.Boolean)
GetCacheFilePath(System.String,System.Nullable`1<System.Int32>,System.Nullable`1<System.Int32>,System.Nullable`1<System.Int32>,System.Nullable`1<System.Int32>,System.Nullable`1<System.Int32>,System.Nullable`1<System.Int32>,System.Int32,System.DateTime,MediaBrowser.Model.Drawing.ImageFormat,System.Double,System.Nullable`1<System.Int32>,System.Nullable`1<System.Int32>,System.String,System.String)
GetImageDimensions(MediaBrowser.Controller.Entities.BaseItem,MediaBrowser.Controller.Entities.ItemImageInfo)
GetImageDimensions(System.String)
GetImageBlurHash(System.String)
GetImageBlurHash(System.String,MediaBrowser.Model.Drawing.ImageDimensions)
GetImageCacheTag(MediaBrowser.Controller.Entities.BaseItem,MediaBrowser.Controller.Entities.ItemImageInfo)
GetImageCacheTag(MediaBrowser.Controller.Entities.BaseItem,MediaBrowser.Model.Entities.ChapterInfo)
GetImageCacheTag(Jellyfin.Data.Entities.User)
GetSupportedImage(System.String,System.DateTime)
GetCachePath(System.String,System.String,System.String)
GetCachePath(System.ReadOnlySpan`1<System.Char>,System.ReadOnlySpan`1<System.Char>)
CreateImageCollage(MediaBrowser.Controller.Drawing.ImageCollageOptions,System.String)
Dispose()