perf(images): support conditional ETag responses

This commit is contained in:
2026-04-05 13:38:50 -04:00
parent 997f60b0a8
commit 7beac7484d
3 changed files with 122 additions and 6 deletions
@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Http;
using allstarr.Services.Common;
namespace allstarr.Tests;
public class ImageConditionalRequestHelperTests
{
[Fact]
public void ComputeStrongETag_SamePayload_ReturnsStableQuotedHash()
{
var payload = new byte[] { 1, 2, 3, 4 };
var first = ImageConditionalRequestHelper.ComputeStrongETag(payload);
var second = ImageConditionalRequestHelper.ComputeStrongETag(payload);
Assert.Equal(first, second);
Assert.StartsWith("\"", first);
Assert.EndsWith("\"", first);
}
[Fact]
public void MatchesIfNoneMatch_WithExactMatch_ReturnsTrue()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "\"ABC123\""
};
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"ABC123\""));
}
[Fact]
public void MatchesIfNoneMatch_WithMultipleValues_ReturnsTrueForMatchingEntry()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "\"stale\", \"fresh\""
};
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"fresh\""));
}
[Fact]
public void MatchesIfNoneMatch_WithWildcard_ReturnsTrue()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "*"
};
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"anything\""));
}
[Fact]
public void MatchesIfNoneMatch_WithoutMatch_ReturnsFalse()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "\"ABC123\""
};
Assert.False(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"XYZ789\""));
}
}
+19 -6
View File
@@ -753,7 +753,7 @@ public partial class JellyfinController : ControllerBase
if (fallbackBytes != null && fallbackContentType != null)
{
return File(fallbackBytes, fallbackContentType);
return CreateConditionalImageResponse(fallbackBytes, fallbackContentType);
}
}
}
@@ -762,7 +762,7 @@ public partial class JellyfinController : ControllerBase
return await GetPlaceholderImageAsync();
}
return File(imageBytes, contentType);
return CreateConditionalImageResponse(imageBytes, contentType);
}
// Check Redis cache for previously fetched external image
@@ -771,7 +771,7 @@ public partial class JellyfinController : ControllerBase
if (cachedImageBytes != null)
{
_logger.LogDebug("Cache hit for external {Type} image: {Provider}/{ExternalId}", type, provider, externalId);
return File(cachedImageBytes, "image/jpeg");
return CreateConditionalImageResponse(cachedImageBytes, "image/jpeg");
}
// Get external cover art URL
@@ -842,7 +842,7 @@ public partial class JellyfinController : ControllerBase
_logger.LogDebug("Successfully fetched and cached external image from host {Host}, size: {Size} bytes",
safeCoverUri.Host, imageBytes.Length);
return File(imageBytes, "image/jpeg");
return CreateConditionalImageResponse(imageBytes, "image/jpeg");
}
catch (Exception ex)
{
@@ -864,7 +864,7 @@ public partial class JellyfinController : ControllerBase
if (System.IO.File.Exists(placeholderPath))
{
var imageBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath);
return File(imageBytes, "image/png");
return CreateConditionalImageResponse(imageBytes, "image/png");
}
// Fallback: Return a 1x1 transparent PNG as minimal placeholder
@@ -872,7 +872,20 @@ public partial class JellyfinController : ControllerBase
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
);
return File(transparentPng, "image/png");
return CreateConditionalImageResponse(transparentPng, "image/png");
}
private IActionResult CreateConditionalImageResponse(byte[] imageBytes, string contentType)
{
var etag = ImageConditionalRequestHelper.ComputeStrongETag(imageBytes);
Response.Headers["ETag"] = etag;
if (ImageConditionalRequestHelper.MatchesIfNoneMatch(Request.Headers, etag))
{
return StatusCode(StatusCodes.Status304NotModified);
}
return File(imageBytes, contentType);
}
private async Task<string?> ResolveCurrentSpotifyPlaylistImageTagAsync(string itemId, string imageType)
@@ -0,0 +1,39 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Http;
namespace allstarr.Services.Common;
public static class ImageConditionalRequestHelper
{
public static string ComputeStrongETag(byte[] payload)
{
var hash = SHA256.HashData(payload);
return $"\"{Convert.ToHexString(hash)}\"";
}
public static bool MatchesIfNoneMatch(IHeaderDictionary headers, string etag)
{
if (!headers.TryGetValue("If-None-Match", out var headerValues))
{
return false;
}
foreach (var headerValue in headerValues)
{
if (string.IsNullOrEmpty(headerValue))
{
continue;
}
foreach (var candidate in headerValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (candidate == "*" || string.Equals(candidate, etag, StringComparison.Ordinal))
{
return true;
}
}
}
return false;
}
}