From 3da05330b27dd94773c7fc1de0d44a65d0a978a7 Mon Sep 17 00:00:00 2001 From: GOvEy1nw <45126527+GOvEy1nw@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:37:36 +0100 Subject: fix(images): disambiguate progress overlay cache keys --- src/Jellyfin.Drawing/ImageProcessor.cs | 26 ++++- src/Jellyfin.Drawing/Properties/AssemblyInfo.cs | 2 + .../ImageProcessorTests.cs | 124 +++++++++++++++++++++ 3 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index 6ffb022842..f906ad10ab 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -31,7 +31,7 @@ namespace Jellyfin.Drawing; public sealed class ImageProcessor : IImageProcessor, IDisposable { // Increment this when there's a change requiring caches to be invalidated - private const char Version = '3'; + private const char Version = '4'; private static readonly HashSet _transparentImageTypes = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif", ".svg" }; @@ -251,7 +251,23 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable /// /// Gets the cache file path based on a set of parameters. /// - private string GetCacheFilePath( + /// The original image path. + /// The requested width. + /// The requested height. + /// The maximum width. + /// The maximum height. + /// The fill width. + /// The fill height. + /// The image quality. + /// The source image modification date. + /// The output format. + /// The played percentage overlay value. + /// The unwatched count overlay value. + /// The blur amount. + /// The background color. + /// The foreground layer. + /// The transformed image cache path. + internal string GetCacheFilePath( string originalPath, int? width, int? height, @@ -318,13 +334,13 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable if (percentPlayed > 0) { - filename.Append(",p="); - filename.Append(percentPlayed); + filename.Append(",pp="); + filename.Append(percentPlayed.ToString(CultureInfo.InvariantCulture)); } if (unwatchedCount.HasValue) { - filename.Append(",p="); + filename.Append(",uc="); filename.Append(unwatchedCount.Value); } diff --git a/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs b/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs index 3851bf9241..3d39372313 100644 --- a/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs +++ b/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -12,6 +13,7 @@ using System.Runtime.InteropServices; [assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] +[assembly: InternalsVisibleTo("Jellyfin.Server.Integration.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs b/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs new file mode 100644 index 0000000000..1307a36f06 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs @@ -0,0 +1,124 @@ +using System; +using System.Globalization; +using System.IO; +using Jellyfin.Drawing; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests; + +public sealed class ImageProcessorTests : IDisposable +{ + private const string CacheRoot = "image-cache"; + private const string OriginalPath = "/media/poster.jpg"; + private const string NoOverlayCacheKey = "/media/poster.jpg,quality=90,datemodified=638800000000000000,f=Jpg,width=200,height=300,maxwidth=400,maxheight=500,fillwidth=600,fillheight=700,blur=2,b=000000,fl=layer,v=4"; + private static readonly DateTime _dateModified = new(638800000000000000, DateTimeKind.Utc); + private readonly ImageProcessor _imageProcessor; + + public ImageProcessorTests() + { + var applicationPaths = new Mock(); + applicationPaths.SetupGet(paths => paths.ImageCachePath).Returns(CacheRoot); + + var configurationManager = new Mock(); + configurationManager + .SetupGet(manager => manager.Configuration) + .Returns(new ServerConfiguration { ParallelImageEncodingLimit = 1 }); + + _imageProcessor = new ImageProcessor( + NullLogger.Instance, + applicationPaths.Object, + Mock.Of(), + Mock.Of(), + configurationManager.Object); + } + + [Fact] + public void GetCacheFilePath_DifferentOverlayTypes_ReturnDifferentPaths() + { + var percentPlayedPath = GetCacheFilePath(percentPlayed: 1); + var unwatchedCountPath = GetCacheFilePath(unwatchedCount: 1); + + Assert.NotEqual(percentPlayedPath, unwatchedCountPath); + } + + [Fact] + public void GetCacheFilePath_DifferentPercentPlayedValues_ReturnDifferentPaths() + { + var firstPath = GetCacheFilePath(percentPlayed: 12.5); + var secondPath = GetCacheFilePath(percentPlayed: 75.5); + + Assert.NotEqual(firstPath, secondPath); + } + + [Fact] + public void GetCacheFilePath_DifferentUnwatchedCountValues_ReturnDifferentPaths() + { + var firstPath = GetCacheFilePath(unwatchedCount: 1); + var secondPath = GetCacheFilePath(unwatchedCount: 2); + + Assert.NotEqual(firstPath, secondPath); + } + + [Fact] + public void GetCacheFilePath_DifferentCultures_ReturnSamePath() + { + var originalCulture = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US"); + var expectedPath = GetCacheFilePath(percentPlayed: 12.5); + + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + var actualPath = GetCacheFilePath(percentPlayed: 12.5); + + Assert.Equal(expectedPath, actualPath); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + + [Fact] + public void GetCacheFilePath_NoOverlay_UsesVersionFourWithExistingSerialization() + { + var expectedPath = _imageProcessor.GetCachePath( + Path.Combine(CacheRoot, "resized-images"), + NoOverlayCacheKey, + ".jpg"); + + Assert.Equal(expectedPath, GetCacheFilePath()); + } + + public void Dispose() + { + _imageProcessor.Dispose(); + } + + private string GetCacheFilePath(double percentPlayed = 0, int? unwatchedCount = null) + => _imageProcessor.GetCacheFilePath( + OriginalPath, + width: 200, + height: 300, + maxWidth: 400, + maxHeight: 500, + fillWidth: 600, + fillHeight: 700, + quality: 90, + dateModified: _dateModified, + format: ImageFormat.Jpg, + percentPlayed, + unwatchedCount, + blur: 2, + backgroundColor: "000000", + foregroundLayer: "layer"); +} -- cgit v1.2.3 From 0915a61c19452e878b955fcb9cc8cb76f72c43a7 Mon Sep 17 00:00:00 2001 From: GOvEy1nw <45126527+GOvEy1nw@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:20:01 +0100 Subject: fix(images): narrow cache path test seam --- src/Jellyfin.Drawing/ImageProcessor.cs | 35 +++++++++++++------- .../ImageProcessorTests.cs | 37 +++++++++++++--------- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index f906ad10ab..ad1b216970 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -252,22 +252,33 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable /// Gets the cache file path based on a set of parameters. /// /// The original image path. - /// The requested width. - /// The requested height. - /// The maximum width. - /// The maximum height. - /// The fill width. - /// The fill height. - /// The image quality. /// The source image modification date. /// The output format. - /// The played percentage overlay value. - /// The unwatched count overlay value. - /// The blur amount. - /// The background color. - /// The foreground layer. + /// The image processing options. /// The transformed image cache path. internal string GetCacheFilePath( + string originalPath, + DateTime dateModified, + ImageFormat format, + ImageProcessingOptions options) + => GetCacheFilePath( + originalPath, + options.Width, + options.Height, + options.MaxWidth, + options.MaxHeight, + options.FillWidth, + options.FillHeight, + options.Quality, + dateModified, + format, + options.PercentPlayed, + options.UnplayedCount, + options.Blur, + options.BackgroundColor, + options.ForegroundLayer); + + private string GetCacheFilePath( string originalPath, int? width, int? height, diff --git a/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs b/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs index 1307a36f06..a1149ac9be 100644 --- a/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs @@ -105,20 +105,27 @@ public sealed class ImageProcessorTests : IDisposable } private string GetCacheFilePath(double percentPlayed = 0, int? unwatchedCount = null) - => _imageProcessor.GetCacheFilePath( + { + var options = new ImageProcessingOptions + { + Width = 200, + Height = 300, + MaxWidth = 400, + MaxHeight = 500, + FillWidth = 600, + FillHeight = 700, + Quality = 90, + PercentPlayed = percentPlayed, + UnplayedCount = unwatchedCount, + Blur = 2, + BackgroundColor = "000000", + ForegroundLayer = "layer" + }; + + return _imageProcessor.GetCacheFilePath( OriginalPath, - width: 200, - height: 300, - maxWidth: 400, - maxHeight: 500, - fillWidth: 600, - fillHeight: 700, - quality: 90, - dateModified: _dateModified, - format: ImageFormat.Jpg, - percentPlayed, - unwatchedCount, - blur: 2, - backgroundColor: "000000", - foregroundLayer: "layer"); + _dateModified, + ImageFormat.Jpg, + options); + } } -- cgit v1.2.3