< Summary - Jellyfin

Information
Class: Jellyfin.Drawing.ImageProcessor
Assembly: Jellyfin.Drawing
File(s): /srv/git/jellyfin/src/Jellyfin.Drawing/ImageProcessor.cs
Line coverage
36%
Covered lines: 84
Uncovered lines: 146
Coverable lines: 230
Total lines: 577
Line coverage: 36.5%
Branch coverage
23%
Covered branches: 20
Total branches: 86
Branch coverage: 23.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/6/2026 - 12:15:23 AM Line coverage: 6.5% (14/214) Branch coverage: 5.8% (5/86) Total lines: 5505/20/2026 - 12:15:44 AM Line coverage: 6.5% (14/214) Branch coverage: 4.6% (4/86) Total lines: 5508/9/2026 - 12:16:58 AM Line coverage: 36.5% (84/230) Branch coverage: 23.2% (20/86) Total lines: 577 5/6/2026 - 12:15:23 AM Line coverage: 6.5% (14/214) Branch coverage: 5.8% (5/86) Total lines: 5505/20/2026 - 12:15:44 AM Line coverage: 6.5% (14/214) Branch coverage: 4.6% (4/86) Total lines: 5508/9/2026 - 12:16:58 AM Line coverage: 36.5% (84/230) Branch coverage: 23.2% (20/86) Total lines: 577

Coverage delta

Coverage delta 30 -30

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.Reflection.Metadata.Ecma335;
 8using System.Text;
 9using System.Threading;
 10using System.Threading.Tasks;
 11using AsyncKeyedLock;
 12using Jellyfin.Database.Implementations.Entities;
 13using MediaBrowser.Common.Extensions;
 14using MediaBrowser.Controller;
 15using MediaBrowser.Controller.Configuration;
 16using MediaBrowser.Controller.Drawing;
 17using MediaBrowser.Controller.Entities;
 18using MediaBrowser.Model.Drawing;
 19using MediaBrowser.Model.Dto;
 20using MediaBrowser.Model.Entities;
 21using MediaBrowser.Model.IO;
 22using MediaBrowser.Model.Net;
 23using Microsoft.Extensions.Logging;
 24using Photo = MediaBrowser.Controller.Entities.Photo;
 25
 26namespace Jellyfin.Drawing;
 27
 28/// <summary>
 29/// Class ImageProcessor.
 30/// </summary>
 31public sealed class ImageProcessor : IImageProcessor, IDisposable
 32{
 33    // Increment this when there's a change requiring caches to be invalidated
 34    private const char Version = '4';
 35
 036    private static readonly HashSet<string> _transparentImageTypes
 037        = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif", ".svg" };
 38
 39    private readonly ILogger<ImageProcessor> _logger;
 40    private readonly IFileSystem _fileSystem;
 41    private readonly IServerApplicationPaths _appPaths;
 42    private readonly IImageEncoder _imageEncoder;
 43
 44    private readonly AsyncNonKeyedLocker _parallelEncodingLimit;
 45
 46    private bool _disposed;
 47
 48    /// <summary>
 49    /// Initializes a new instance of the <see cref="ImageProcessor"/> class.
 50    /// </summary>
 51    /// <param name="logger">The logger.</param>
 52    /// <param name="appPaths">The server application paths.</param>
 53    /// <param name="fileSystem">The filesystem.</param>
 54    /// <param name="imageEncoder">The image encoder.</param>
 55    /// <param name="config">The configuration.</param>
 56    public ImageProcessor(
 57        ILogger<ImageProcessor> logger,
 58        IServerApplicationPaths appPaths,
 59        IFileSystem fileSystem,
 60        IImageEncoder imageEncoder,
 61        IServerConfigurationManager config)
 62    {
 2763        _logger = logger;
 2764        _fileSystem = fileSystem;
 2765        _imageEncoder = imageEncoder;
 2766        _appPaths = appPaths;
 67
 2768        var semaphoreCount = config.Configuration.ParallelImageEncodingLimit;
 2769        if (semaphoreCount < 1)
 70        {
 2271            semaphoreCount = Environment.ProcessorCount;
 72        }
 73
 2774        _parallelEncodingLimit = new(semaphoreCount);
 2775    }
 76
 977    private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images");
 78
 79    /// <inheritdoc />
 80    public IReadOnlyCollection<string> SupportedInputFormats =>
 081        new HashSet<string>(StringComparer.OrdinalIgnoreCase)
 082        {
 083            "tiff",
 084            "tif",
 085            "jpeg",
 086            "jpg",
 087            "png",
 088            "cr2",
 089            "crw",
 090            "nef",
 091            "orf",
 092            "pef",
 093            "arw",
 094            "webp",
 095            "gif",
 096            "bmp",
 097            "erf",
 098            "raf",
 099            "rw2",
 0100            "nrw",
 0101            "dng",
 0102            "ico",
 0103            "astc",
 0104            "ktx",
 0105            "pkm",
 0106            "wbmp",
 0107            "avif"
 0108        };
 109
 110    /// <inheritdoc />
 0111    public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation;
 112
 113    /// <inheritdoc />
 114    public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats()
 0115        => _imageEncoder.SupportedOutputFormats;
 116
 117    /// <inheritdoc />
 118    public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions option
 119    {
 0120        ItemImageInfo originalImage = options.Image;
 0121        BaseItem item = options.Item;
 122
 0123        string originalImagePath = originalImage.Path;
 0124        DateTime dateModified = originalImage.DateModified;
 0125        ImageDimensions? originalImageSize = null;
 0126        if (originalImage.Width > 0 && originalImage.Height > 0)
 127        {
 0128            originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height);
 129        }
 130
 0131        var mimeType = MimeTypes.GetMimeType(originalImagePath);
 0132        if (!_imageEncoder.SupportsImageEncoding)
 133        {
 0134            return (originalImagePath, mimeType, dateModified);
 135        }
 136
 0137        var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false);
 0138        originalImagePath = supportedImageInfo.Path;
 139
 140        // Original file doesn't exist, or original file is gif.
 0141        if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.Ordina
 142        {
 0143            return (originalImagePath, mimeType, dateModified);
 144        }
 145
 0146        dateModified = supportedImageInfo.DateModified;
 0147        bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath));
 148
 0149        bool autoOrient = false;
 0150        ImageOrientation? orientation = null;
 0151        if (item is Photo photo)
 152        {
 0153            if (photo.Orientation.HasValue)
 154            {
 0155                if (photo.Orientation.Value != ImageOrientation.TopLeft)
 156                {
 0157                    autoOrient = true;
 0158                    orientation = photo.Orientation;
 159                }
 160            }
 161            else
 162            {
 163                // Orientation unknown, so do it
 0164                autoOrient = true;
 0165                orientation = photo.Orientation;
 166            }
 167        }
 168
 0169        if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrie
 170        {
 171            // Just spit out the original file if all the options are default
 0172            return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
 173        }
 174
 0175        int quality = options.Quality;
 176
 0177        ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
 0178        string cacheFilePath = GetCacheFilePath(
 0179            originalImagePath,
 0180            options.Width,
 0181            options.Height,
 0182            options.MaxWidth,
 0183            options.MaxHeight,
 0184            options.FillWidth,
 0185            options.FillHeight,
 0186            quality,
 0187            dateModified,
 0188            outputFormat,
 0189            options.PercentPlayed,
 0190            options.UnplayedCount,
 0191            options.Blur,
 0192            options.BackgroundColor,
 0193            options.ForegroundLayer);
 194
 195        try
 196        {
 0197            if (!File.Exists(cacheFilePath))
 198            {
 199                string resultPath;
 200
 201                // Limit number of parallel (more precisely: concurrent) image encodings to prevent a high memory usage
 0202                using (await _parallelEncodingLimit.LockAsync().ConfigureAwait(false))
 203                {
 0204                    resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, o
 0205                }
 206
 0207                if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
 208                {
 0209                    return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
 210                }
 211            }
 212
 0213            return (cacheFilePath, outputFormat.GetMimeType(), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
 214        }
 0215        catch (Exception ex)
 216        {
 217            // If it fails for whatever reason, return the original image
 0218            _logger.LogError(ex, "Error encoding image");
 0219            return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
 220        }
 0221    }
 222
 223    private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparen
 224    {
 0225        var serverFormats = GetSupportedImageOutputFormats();
 226
 227        // Client doesn't care about format, so start with webp if supported
 0228        if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp))
 229        {
 0230            return ImageFormat.Webp;
 231        }
 232
 233        // If transparency is needed and webp isn't supported, than png is the only option
 0234        if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png))
 235        {
 0236            return ImageFormat.Png;
 237        }
 238
 0239        foreach (var format in clientSupportedFormats)
 240        {
 0241            if (serverFormats.Contains(format))
 242            {
 0243                return format;
 244            }
 245        }
 246
 247        // We should never actually get here
 0248        return ImageFormat.Jpg;
 0249    }
 250
 251    /// <summary>
 252    /// Gets the cache file path based on a set of parameters.
 253    /// </summary>
 254    /// <param name="originalPath">The original image path.</param>
 255    /// <param name="dateModified">The source image modification date.</param>
 256    /// <param name="format">The output format.</param>
 257    /// <param name="options">The image processing options.</param>
 258    /// <returns>The transformed image cache path.</returns>
 259    internal string GetCacheFilePath(
 260        string originalPath,
 261        DateTime dateModified,
 262        ImageFormat format,
 263        ImageProcessingOptions options)
 9264        => GetCacheFilePath(
 9265            originalPath,
 9266            options.Width,
 9267            options.Height,
 9268            options.MaxWidth,
 9269            options.MaxHeight,
 9270            options.FillWidth,
 9271            options.FillHeight,
 9272            options.Quality,
 9273            dateModified,
 9274            format,
 9275            options.PercentPlayed,
 9276            options.UnplayedCount,
 9277            options.Blur,
 9278            options.BackgroundColor,
 9279            options.ForegroundLayer);
 280
 281    private string GetCacheFilePath(
 282        string originalPath,
 283        int? width,
 284        int? height,
 285        int? maxWidth,
 286        int? maxHeight,
 287        int? fillWidth,
 288        int? fillHeight,
 289        int quality,
 290        DateTime dateModified,
 291        ImageFormat format,
 292        double percentPlayed,
 293        int? unwatchedCount,
 294        int? blur,
 295        string backgroundColor,
 296        string foregroundLayer)
 297    {
 9298        var filename = new StringBuilder(256);
 9299        filename.Append(originalPath);
 300
 9301        filename.Append(",quality=");
 9302        filename.Append(quality);
 303
 9304        filename.Append(",datemodified=");
 9305        filename.Append(dateModified.Ticks);
 306
 9307        filename.Append(",f=");
 9308        filename.Append(format);
 309
 9310        if (width.HasValue)
 311        {
 9312            filename.Append(",width=");
 9313            filename.Append(width.Value);
 314        }
 315
 9316        if (height.HasValue)
 317        {
 9318            filename.Append(",height=");
 9319            filename.Append(height.Value);
 320        }
 321
 9322        if (maxWidth.HasValue)
 323        {
 9324            filename.Append(",maxwidth=");
 9325            filename.Append(maxWidth.Value);
 326        }
 327
 9328        if (maxHeight.HasValue)
 329        {
 9330            filename.Append(",maxheight=");
 9331            filename.Append(maxHeight.Value);
 332        }
 333
 9334        if (fillWidth.HasValue)
 335        {
 9336            filename.Append(",fillwidth=");
 9337            filename.Append(fillWidth.Value);
 338        }
 339
 9340        if (fillHeight.HasValue)
 341        {
 9342            filename.Append(",fillheight=");
 9343            filename.Append(fillHeight.Value);
 344        }
 345
 9346        if (percentPlayed > 0)
 347        {
 5348            filename.Append(",pp=");
 5349            filename.Append(percentPlayed.ToString(CultureInfo.InvariantCulture));
 350        }
 351
 9352        if (unwatchedCount.HasValue)
 353        {
 3354            filename.Append(",uc=");
 3355            filename.Append(unwatchedCount.Value);
 356        }
 357
 9358        if (blur.HasValue)
 359        {
 9360            filename.Append(",blur=");
 9361            filename.Append(blur.Value);
 362        }
 363
 9364        if (!string.IsNullOrEmpty(backgroundColor))
 365        {
 9366            filename.Append(",b=");
 9367            filename.Append(backgroundColor);
 368        }
 369
 9370        if (!string.IsNullOrEmpty(foregroundLayer))
 371        {
 9372            filename.Append(",fl=");
 9373            filename.Append(foregroundLayer);
 374        }
 375
 9376        filename.Append(",v=");
 9377        filename.Append(Version);
 378
 9379        return GetCachePath(ResizedImageCachePath, filename.ToString(), format.GetExtension());
 380    }
 381
 382    /// <inheritdoc />
 383    public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info)
 384    {
 0385        int width = info.Width;
 0386        int height = info.Height;
 387
 0388        if (height > 0 && width > 0)
 389        {
 0390            return new ImageDimensions(width, height);
 391        }
 392
 0393        string path = info.Path;
 0394        _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path);
 395
 0396        ImageDimensions size = GetImageDimensions(path);
 0397        info.Width = size.Width;
 0398        info.Height = size.Height;
 399
 0400        return size;
 401    }
 402
 403    /// <inheritdoc />
 404    public ImageDimensions GetImageDimensions(string path)
 0405        => _imageEncoder.GetImageSize(path);
 406
 407    /// <inheritdoc />
 408    public string GetImageBlurHash(string path)
 409    {
 0410        var size = GetImageDimensions(path);
 0411        return GetImageBlurHash(path, size);
 412    }
 413
 414    /// <inheritdoc />
 415    public string GetImageBlurHash(string path, ImageDimensions imageDimensions)
 416    {
 0417        if (imageDimensions.Width <= 0 || imageDimensions.Height <= 0)
 418        {
 0419            return string.Empty;
 420        }
 421
 422        // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance.
 423        // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / widt
 424        // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components
 0425        float xCompF = MathF.Sqrt(16.0f * imageDimensions.Width / imageDimensions.Height);
 0426        float yCompF = xCompF * imageDimensions.Height / imageDimensions.Width;
 427
 0428        int xComp = Math.Min((int)xCompF + 1, 9);
 0429        int yComp = Math.Min((int)yCompF + 1, 9);
 430
 0431        return _imageEncoder.GetImageBlurHash(xComp, yComp, path);
 432    }
 433
 434    /// <inheritdoc />
 435    public string GetImageCacheTag(string baseItemPath, DateTime imageDateModified)
 0436        => (baseItemPath + imageDateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
 437
 438    /// <inheritdoc />
 439    public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
 0440        => GetImageCacheTag(item.Path, image.DateModified);
 441
 442    /// <inheritdoc />
 443    public string GetImageCacheTag(BaseItemDto item, ItemImageInfo image)
 0444        => GetImageCacheTag(item.Path, image.DateModified);
 445
 446    /// <inheritdoc />
 447    public string? GetImageCacheTag(BaseItemDto item, ChapterInfo chapter)
 448    {
 0449        if (chapter.ImagePath is null)
 450        {
 0451            return null;
 452        }
 453
 0454        return GetImageCacheTag(item.Path, chapter.ImageDateModified);
 455    }
 456
 457    /// <inheritdoc />
 458    public string? GetImageCacheTag(BaseItem item, ChapterInfo chapter)
 459    {
 0460        if (chapter.ImagePath is null)
 461        {
 0462            return null;
 463        }
 464
 0465        return GetImageCacheTag(item, new ItemImageInfo
 0466        {
 0467            Path = chapter.ImagePath,
 0468            Type = ImageType.Chapter,
 0469            DateModified = chapter.ImageDateModified
 0470        });
 471    }
 472
 473    /// <inheritdoc />
 474    public string? GetImageCacheTag(User user)
 475    {
 0476        if (user.ProfileImage is null)
 477        {
 0478            return null;
 479        }
 480
 0481        return GetImageCacheTag(user.ProfileImage.Path, user.ProfileImage.LastModified);
 482    }
 483
 484    private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified
 485    {
 0486        var inputFormat = Path.GetExtension(originalImagePath.AsSpan()).TrimStart('.').ToString();
 487
 488        // These are just jpg files renamed as tbn
 0489        if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase))
 490        {
 0491            return Task.FromResult((originalImagePath, dateModified));
 492        }
 493
 494        return Task.FromResult((originalImagePath, dateModified));
 495    }
 496
 497    /// <summary>
 498    /// Gets the cache path.
 499    /// </summary>
 500    /// <param name="path">The path.</param>
 501    /// <param name="uniqueName">Name of the unique.</param>
 502    /// <param name="fileExtension">The file extension.</param>
 503    /// <returns>System.String.</returns>
 504    /// <exception cref="ArgumentNullException">
 505    /// path
 506    /// or
 507    /// uniqueName
 508    /// or
 509    /// fileExtension.
 510    /// </exception>
 511    public string GetCachePath(string path, string uniqueName, string fileExtension)
 512    {
 10513        ArgumentException.ThrowIfNullOrEmpty(path);
 10514        ArgumentException.ThrowIfNullOrEmpty(uniqueName);
 10515        ArgumentException.ThrowIfNullOrEmpty(fileExtension);
 516
 10517        var filename = uniqueName.GetMD5() + fileExtension;
 518
 10519        return GetCachePath(path, filename);
 520    }
 521
 522    /// <summary>
 523    /// Gets the cache path.
 524    /// </summary>
 525    /// <param name="path">The path.</param>
 526    /// <param name="filename">The filename.</param>
 527    /// <returns>System.String.</returns>
 528    /// <exception cref="ArgumentNullException">
 529    /// path
 530    /// or
 531    /// filename.
 532    /// </exception>
 533    public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename)
 534    {
 10535        if (path.IsEmpty)
 536        {
 0537            throw new ArgumentException("Path can't be empty.", nameof(path));
 538        }
 539
 10540        if (filename.IsEmpty)
 541        {
 0542            throw new ArgumentException("Filename can't be empty.", nameof(filename));
 543        }
 544
 10545        var prefix = filename.Slice(0, 1);
 546
 10547        return Path.Join(path, prefix, filename);
 548    }
 549
 550    /// <inheritdoc />
 551    public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
 552    {
 0553        _logger.LogDebug("Creating image collage and saving to {Path}", options.OutputPath);
 554
 0555        _imageEncoder.CreateImageCollage(options, libraryName);
 556
 0557        _logger.LogDebug("Completed creation of image collage and saved to {Path}", options.OutputPath);
 0558    }
 559
 560    /// <inheritdoc />
 561    public void Dispose()
 562    {
 27563        if (_disposed)
 564        {
 0565            return;
 566        }
 567
 27568        if (_imageEncoder is IDisposable disposable)
 569        {
 0570            disposable.Dispose();
 571        }
 572
 27573        _parallelEncodingLimit?.Dispose();
 574
 27575        _disposed = true;
 27576    }
 577}

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()
ProcessImage()
GetOutputFormat(System.Collections.Generic.IReadOnlyCollection`1<MediaBrowser.Model.Drawing.ImageFormat>,System.Boolean)
GetCacheFilePath(System.String,System.DateTime,MediaBrowser.Model.Drawing.ImageFormat,MediaBrowser.Controller.Drawing.ImageProcessingOptions)
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(System.String,System.DateTime)
GetImageCacheTag(MediaBrowser.Controller.Entities.BaseItem,MediaBrowser.Controller.Entities.ItemImageInfo)
GetImageCacheTag(MediaBrowser.Model.Dto.BaseItemDto,MediaBrowser.Controller.Entities.ItemImageInfo)
GetImageCacheTag(MediaBrowser.Model.Dto.BaseItemDto,MediaBrowser.Model.Entities.ChapterInfo)
GetImageCacheTag(MediaBrowser.Controller.Entities.BaseItem,MediaBrowser.Model.Entities.ChapterInfo)
GetImageCacheTag(Jellyfin.Database.Implementations.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()