< Summary - Jellyfin

Information
Class: Jellyfin.Drawing.ImageProcessor
Assembly: Jellyfin.Drawing
File(s): /srv/git/jellyfin/src/Jellyfin.Drawing/ImageProcessor.cs
Line coverage
8%
Covered lines: 14
Uncovered lines: 144
Coverable lines: 158
Total lines: 550
Line coverage: 8.8%
Branch coverage
8%
Covered branches: 5
Total branches: 60
Branch coverage: 8.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 12/6/2025 - 12:11:15 AM Line coverage: 8.8% (14/159) Branch coverage: 8.3% (5/60) Total lines: 5513/3/2026 - 12:13:24 AM Line coverage: 8.8% (14/158) Branch coverage: 8.3% (5/60) Total lines: 550 12/6/2025 - 12:11:15 AM Line coverage: 8.8% (14/159) Branch coverage: 8.3% (5/60) Total lines: 5513/3/2026 - 12:13:24 AM Line coverage: 8.8% (14/158) Branch coverage: 8.3% (5/60) Total lines: 550

Coverage delta

Coverage delta 1 -1

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 = '3';
 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    {
 2163        _logger = logger;
 2164        _fileSystem = fileSystem;
 2165        _imageEncoder = imageEncoder;
 2166        _appPaths = appPaths;
 67
 2168        var semaphoreCount = config.Configuration.ParallelImageEncodingLimit;
 2169        if (semaphoreCount < 1)
 70        {
 2171            semaphoreCount = Environment.ProcessorCount;
 72        }
 73
 2174        _parallelEncodingLimit = new(semaphoreCount);
 2175    }
 76
 077    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    {
 120        ItemImageInfo originalImage = options.Image;
 121        BaseItem item = options.Item;
 122
 123        string originalImagePath = originalImage.Path;
 124        DateTime dateModified = originalImage.DateModified;
 125        ImageDimensions? originalImageSize = null;
 126        if (originalImage.Width > 0 && originalImage.Height > 0)
 127        {
 128            originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height);
 129        }
 130
 131        var mimeType = MimeTypes.GetMimeType(originalImagePath);
 132        if (!_imageEncoder.SupportsImageEncoding)
 133        {
 134            return (originalImagePath, mimeType, dateModified);
 135        }
 136
 137        var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false);
 138        originalImagePath = supportedImageInfo.Path;
 139
 140        // Original file doesn't exist, or original file is gif.
 141        if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.Ordina
 142        {
 143            return (originalImagePath, mimeType, dateModified);
 144        }
 145
 146        dateModified = supportedImageInfo.DateModified;
 147        bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath));
 148
 149        bool autoOrient = false;
 150        ImageOrientation? orientation = null;
 151        if (item is Photo photo)
 152        {
 153            if (photo.Orientation.HasValue)
 154            {
 155                if (photo.Orientation.Value != ImageOrientation.TopLeft)
 156                {
 157                    autoOrient = true;
 158                    orientation = photo.Orientation;
 159                }
 160            }
 161            else
 162            {
 163                // Orientation unknown, so do it
 164                autoOrient = true;
 165                orientation = photo.Orientation;
 166            }
 167        }
 168
 169        if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrie
 170        {
 171            // Just spit out the original file if all the options are default
 172            return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
 173        }
 174
 175        int quality = options.Quality;
 176
 177        ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
 178        string cacheFilePath = GetCacheFilePath(
 179            originalImagePath,
 180            options.Width,
 181            options.Height,
 182            options.MaxWidth,
 183            options.MaxHeight,
 184            options.FillWidth,
 185            options.FillHeight,
 186            quality,
 187            dateModified,
 188            outputFormat,
 189            options.PercentPlayed,
 190            options.UnplayedCount,
 191            options.Blur,
 192            options.BackgroundColor,
 193            options.ForegroundLayer);
 194
 195        try
 196        {
 197            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
 202                using (await _parallelEncodingLimit.LockAsync().ConfigureAwait(false))
 203                {
 204                    resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, o
 205                }
 206
 207                if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
 208                {
 209                    return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
 210                }
 211            }
 212
 213            return (cacheFilePath, outputFormat.GetMimeType(), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
 214        }
 215        catch (Exception ex)
 216        {
 217            // If it fails for whatever reason, return the original image
 218            _logger.LogError(ex, "Error encoding image");
 219            return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
 220        }
 221    }
 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    private string GetCacheFilePath(
 255        string originalPath,
 256        int? width,
 257        int? height,
 258        int? maxWidth,
 259        int? maxHeight,
 260        int? fillWidth,
 261        int? fillHeight,
 262        int quality,
 263        DateTime dateModified,
 264        ImageFormat format,
 265        double percentPlayed,
 266        int? unwatchedCount,
 267        int? blur,
 268        string backgroundColor,
 269        string foregroundLayer)
 270    {
 0271        var filename = new StringBuilder(256);
 0272        filename.Append(originalPath);
 273
 0274        filename.Append(",quality=");
 0275        filename.Append(quality);
 276
 0277        filename.Append(",datemodified=");
 0278        filename.Append(dateModified.Ticks);
 279
 0280        filename.Append(",f=");
 0281        filename.Append(format);
 282
 0283        if (width.HasValue)
 284        {
 0285            filename.Append(",width=");
 0286            filename.Append(width.Value);
 287        }
 288
 0289        if (height.HasValue)
 290        {
 0291            filename.Append(",height=");
 0292            filename.Append(height.Value);
 293        }
 294
 0295        if (maxWidth.HasValue)
 296        {
 0297            filename.Append(",maxwidth=");
 0298            filename.Append(maxWidth.Value);
 299        }
 300
 0301        if (maxHeight.HasValue)
 302        {
 0303            filename.Append(",maxheight=");
 0304            filename.Append(maxHeight.Value);
 305        }
 306
 0307        if (fillWidth.HasValue)
 308        {
 0309            filename.Append(",fillwidth=");
 0310            filename.Append(fillWidth.Value);
 311        }
 312
 0313        if (fillHeight.HasValue)
 314        {
 0315            filename.Append(",fillheight=");
 0316            filename.Append(fillHeight.Value);
 317        }
 318
 0319        if (percentPlayed > 0)
 320        {
 0321            filename.Append(",p=");
 0322            filename.Append(percentPlayed);
 323        }
 324
 0325        if (unwatchedCount.HasValue)
 326        {
 0327            filename.Append(",p=");
 0328            filename.Append(unwatchedCount.Value);
 329        }
 330
 0331        if (blur.HasValue)
 332        {
 0333            filename.Append(",blur=");
 0334            filename.Append(blur.Value);
 335        }
 336
 0337        if (!string.IsNullOrEmpty(backgroundColor))
 338        {
 0339            filename.Append(",b=");
 0340            filename.Append(backgroundColor);
 341        }
 342
 0343        if (!string.IsNullOrEmpty(foregroundLayer))
 344        {
 0345            filename.Append(",fl=");
 0346            filename.Append(foregroundLayer);
 347        }
 348
 0349        filename.Append(",v=");
 0350        filename.Append(Version);
 351
 0352        return GetCachePath(ResizedImageCachePath, filename.ToString(), format.GetExtension());
 353    }
 354
 355    /// <inheritdoc />
 356    public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info)
 357    {
 0358        int width = info.Width;
 0359        int height = info.Height;
 360
 0361        if (height > 0 && width > 0)
 362        {
 0363            return new ImageDimensions(width, height);
 364        }
 365
 0366        string path = info.Path;
 0367        _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path);
 368
 0369        ImageDimensions size = GetImageDimensions(path);
 0370        info.Width = size.Width;
 0371        info.Height = size.Height;
 372
 0373        return size;
 374    }
 375
 376    /// <inheritdoc />
 377    public ImageDimensions GetImageDimensions(string path)
 0378        => _imageEncoder.GetImageSize(path);
 379
 380    /// <inheritdoc />
 381    public string GetImageBlurHash(string path)
 382    {
 0383        var size = GetImageDimensions(path);
 0384        return GetImageBlurHash(path, size);
 385    }
 386
 387    /// <inheritdoc />
 388    public string GetImageBlurHash(string path, ImageDimensions imageDimensions)
 389    {
 0390        if (imageDimensions.Width <= 0 || imageDimensions.Height <= 0)
 391        {
 0392            return string.Empty;
 393        }
 394
 395        // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance.
 396        // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / widt
 397        // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components
 0398        float xCompF = MathF.Sqrt(16.0f * imageDimensions.Width / imageDimensions.Height);
 0399        float yCompF = xCompF * imageDimensions.Height / imageDimensions.Width;
 400
 0401        int xComp = Math.Min((int)xCompF + 1, 9);
 0402        int yComp = Math.Min((int)yCompF + 1, 9);
 403
 0404        return _imageEncoder.GetImageBlurHash(xComp, yComp, path);
 405    }
 406
 407    /// <inheritdoc />
 408    public string GetImageCacheTag(string baseItemPath, DateTime imageDateModified)
 0409        => (baseItemPath + imageDateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
 410
 411    /// <inheritdoc />
 412    public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
 0413        => GetImageCacheTag(item.Path, image.DateModified);
 414
 415    /// <inheritdoc />
 416    public string GetImageCacheTag(BaseItemDto item, ItemImageInfo image)
 0417        => GetImageCacheTag(item.Path, image.DateModified);
 418
 419    /// <inheritdoc />
 420    public string? GetImageCacheTag(BaseItemDto item, ChapterInfo chapter)
 421    {
 0422        if (chapter.ImagePath is null)
 423        {
 0424            return null;
 425        }
 426
 0427        return GetImageCacheTag(item.Path, chapter.ImageDateModified);
 428    }
 429
 430    /// <inheritdoc />
 431    public string? GetImageCacheTag(BaseItem item, ChapterInfo chapter)
 432    {
 0433        if (chapter.ImagePath is null)
 434        {
 0435            return null;
 436        }
 437
 0438        return GetImageCacheTag(item, new ItemImageInfo
 0439        {
 0440            Path = chapter.ImagePath,
 0441            Type = ImageType.Chapter,
 0442            DateModified = chapter.ImageDateModified
 0443        });
 444    }
 445
 446    /// <inheritdoc />
 447    public string? GetImageCacheTag(User user)
 448    {
 0449        if (user.ProfileImage is null)
 450        {
 0451            return null;
 452        }
 453
 0454        return GetImageCacheTag(user.ProfileImage.Path, user.ProfileImage.LastModified);
 455    }
 456
 457    private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified
 458    {
 0459        var inputFormat = Path.GetExtension(originalImagePath.AsSpan()).TrimStart('.').ToString();
 460
 461        // These are just jpg files renamed as tbn
 0462        if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase))
 463        {
 0464            return Task.FromResult((originalImagePath, dateModified));
 465        }
 466
 467        return Task.FromResult((originalImagePath, dateModified));
 468    }
 469
 470    /// <summary>
 471    /// Gets the cache path.
 472    /// </summary>
 473    /// <param name="path">The path.</param>
 474    /// <param name="uniqueName">Name of the unique.</param>
 475    /// <param name="fileExtension">The file extension.</param>
 476    /// <returns>System.String.</returns>
 477    /// <exception cref="ArgumentNullException">
 478    /// path
 479    /// or
 480    /// uniqueName
 481    /// or
 482    /// fileExtension.
 483    /// </exception>
 484    public string GetCachePath(string path, string uniqueName, string fileExtension)
 485    {
 0486        ArgumentException.ThrowIfNullOrEmpty(path);
 0487        ArgumentException.ThrowIfNullOrEmpty(uniqueName);
 0488        ArgumentException.ThrowIfNullOrEmpty(fileExtension);
 489
 0490        var filename = uniqueName.GetMD5() + fileExtension;
 491
 0492        return GetCachePath(path, filename);
 493    }
 494
 495    /// <summary>
 496    /// Gets the cache path.
 497    /// </summary>
 498    /// <param name="path">The path.</param>
 499    /// <param name="filename">The filename.</param>
 500    /// <returns>System.String.</returns>
 501    /// <exception cref="ArgumentNullException">
 502    /// path
 503    /// or
 504    /// filename.
 505    /// </exception>
 506    public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename)
 507    {
 0508        if (path.IsEmpty)
 509        {
 0510            throw new ArgumentException("Path can't be empty.", nameof(path));
 511        }
 512
 0513        if (filename.IsEmpty)
 514        {
 0515            throw new ArgumentException("Filename can't be empty.", nameof(filename));
 516        }
 517
 0518        var prefix = filename.Slice(0, 1);
 519
 0520        return Path.Join(path, prefix, filename);
 521    }
 522
 523    /// <inheritdoc />
 524    public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
 525    {
 0526        _logger.LogDebug("Creating image collage and saving to {Path}", options.OutputPath);
 527
 0528        _imageEncoder.CreateImageCollage(options, libraryName);
 529
 0530        _logger.LogDebug("Completed creation of image collage and saved to {Path}", options.OutputPath);
 0531    }
 532
 533    /// <inheritdoc />
 534    public void Dispose()
 535    {
 21536        if (_disposed)
 537        {
 0538            return;
 539        }
 540
 21541        if (_imageEncoder is IDisposable disposable)
 542        {
 0543            disposable.Dispose();
 544        }
 545
 21546        _parallelEncodingLimit?.Dispose();
 547
 21548        _disposed = true;
 21549    }
 550}

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