mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-25 03:12:54 -04:00
perf(images): support conditional ETag responses
This commit is contained in:
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user