aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCody Robibero <cody@robibe.ro>2026-08-07 21:42:16 -0400
committerGitHub <noreply@github.com>2026-08-07 21:42:16 -0400
commit2218f2931c5ae2f2c7c676d321c9d6803f066782 (patch)
tree919a4f70ec9c6a6f18303705debf6b6d02d1709b
parent6bc1c1800454518184fec90b2c7d2d96ca000c55 (diff)
parent0915a61c19452e878b955fcb9cc8cb76f72c43a7 (diff)
Merge pull request #17492 from GOvEy1nw/fix/image-cache-overlay-key
fix(images): disambiguate progress overlay cache keys
-rw-r--r--src/Jellyfin.Drawing/ImageProcessor.cs35
-rw-r--r--src/Jellyfin.Drawing/Properties/AssemblyInfo.cs2
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs131
3 files changed, 164 insertions, 4 deletions
diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs
index 6ffb022842..ad1b216970 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<string> _transparentImageTypes
= new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif", ".svg" };
@@ -251,6 +251,33 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
/// <summary>
/// Gets the cache file path based on a set of parameters.
/// </summary>
+ /// <param name="originalPath">The original image path.</param>
+ /// <param name="dateModified">The source image modification date.</param>
+ /// <param name="format">The output format.</param>
+ /// <param name="options">The image processing options.</param>
+ /// <returns>The transformed image cache path.</returns>
+ 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,
@@ -318,13 +345,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..a1149ac9be
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs
@@ -0,0 +1,131 @@
+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<IServerApplicationPaths>();
+ applicationPaths.SetupGet(paths => paths.ImageCachePath).Returns(CacheRoot);
+
+ var configurationManager = new Mock<IServerConfigurationManager>();
+ configurationManager
+ .SetupGet(manager => manager.Configuration)
+ .Returns(new ServerConfiguration { ParallelImageEncodingLimit = 1 });
+
+ _imageProcessor = new ImageProcessor(
+ NullLogger<ImageProcessor>.Instance,
+ applicationPaths.Object,
+ Mock.Of<IFileSystem>(),
+ Mock.Of<IImageEncoder>(),
+ 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)
+ {
+ 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,
+ _dateModified,
+ ImageFormat.Jpg,
+ options);
+ }
+}