fix: stale playlist artwork
CI / build-and-test (push) Has been cancelled

This commit is contained in:
2026-03-30 02:40:29 -04:00
parent 51d3d784b5
commit 8d3fde8fb9
5 changed files with 164 additions and 1 deletions
@@ -0,0 +1,65 @@
using System.Reflection;
using System.Text.Json;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinImageTagExtractionTests
{
[Fact]
public void ExtractImageTag_WithMatchingImageTagsObject_ReturnsRequestedTag()
{
using var document = JsonDocument.Parse("""
{
"ImageTags": {
"Primary": "playlist-primary-tag",
"Backdrop": "playlist-backdrop-tag"
}
}
""");
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
Assert.Equal("playlist-primary-tag", imageTag);
}
[Fact]
public void ExtractImageTag_WithPrimaryImageTagFallback_ReturnsFallbackTag()
{
using var document = JsonDocument.Parse("""
{
"PrimaryImageTag": "primary-fallback-tag"
}
""");
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
Assert.Equal("primary-fallback-tag", imageTag);
}
[Fact]
public void ExtractImageTag_WithoutMatchingTag_ReturnsNull()
{
using var document = JsonDocument.Parse("""
{
"ImageTags": {
"Backdrop": "playlist-backdrop-tag"
}
}
""");
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
Assert.Null(imageTag);
}
private static string? InvokeExtractImageTag(JsonElement item, string imageType)
{
var method = typeof(JellyfinController).GetMethod(
"ExtractImageTag",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
return (string?)method!.Invoke(null, new object?[] { item, imageType });
}
}
+26
View File
@@ -5,6 +5,7 @@ using allstarr.Models.Domain;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http.Features;
namespace allstarr.Controllers;
@@ -75,7 +76,9 @@ public partial class JellyfinController
Request.Headers);
HttpContext.Response.RegisterForDispose(upstreamResponse);
HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
Response.StatusCode = (int)upstreamResponse.StatusCode;
Response.Headers["X-Accel-Buffering"] = "no";
CopyPassthroughResponseHeaders(upstreamResponse);
@@ -492,6 +495,29 @@ public partial class JellyfinController
return parts[1];
}
private static string? ExtractImageTag(JsonElement item, string imageType)
{
if (item.TryGetProperty("ImageTags", out var imageTags) &&
imageTags.ValueKind == JsonValueKind.Object)
{
foreach (var imageTag in imageTags.EnumerateObject())
{
if (string.Equals(imageTag.Name, imageType, StringComparison.OrdinalIgnoreCase))
{
return imageTag.Value.GetString();
}
}
}
if (string.Equals(imageType, "Primary", StringComparison.OrdinalIgnoreCase) &&
item.TryGetProperty("PrimaryImageTag", out var primaryImageTag))
{
return primaryImageTag.GetString();
}
return null;
}
/// <summary>
/// Determines whether Spotify playlist count enrichment should run for a response.
/// We only run enrichment for playlist-oriented payloads to avoid mutating unrelated item lists
+42 -1
View File
@@ -628,13 +628,20 @@ public partial class JellyfinController : ControllerBase
if (!isExternal)
{
var effectiveImageTag = tag;
if (string.IsNullOrWhiteSpace(effectiveImageTag) &&
_spotifySettings.IsSpotifyPlaylist(itemId))
{
effectiveImageTag = await ResolveCurrentSpotifyPlaylistImageTagAsync(itemId, imageType);
}
// Proxy image from Jellyfin for local content
var (imageBytes, contentType) = await _proxyService.GetImageAsync(
itemId,
imageType,
maxWidth,
maxHeight,
tag);
effectiveImageTag);
if (imageBytes == null || contentType == null)
{
@@ -793,6 +800,40 @@ public partial class JellyfinController : ControllerBase
return File(transparentPng, "image/png");
}
private async Task<string?> ResolveCurrentSpotifyPlaylistImageTagAsync(string itemId, string imageType)
{
try
{
var (itemResult, statusCode) = await _proxyService.GetJsonAsyncInternal($"Items/{itemId}");
if (itemResult == null || statusCode != 200)
{
return null;
}
using var itemDocument = itemResult;
var imageTag = ExtractImageTag(itemDocument.RootElement, imageType);
if (!string.IsNullOrWhiteSpace(imageTag))
{
_logger.LogDebug(
"Resolved current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}: {ImageTag}",
imageType,
itemId,
imageTag);
}
return imageTag;
}
catch (Exception ex)
{
_logger.LogDebug(ex,
"Failed to resolve current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}",
imageType,
itemId);
return null;
}
}
#endregion
#region Favorites
@@ -247,6 +247,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
// Re-fetch
await GetPlaylistTracksAsync(playlistName);
await ClearPlaylistImageCacheAsync(playlistName);
}
/// <summary>
@@ -262,6 +263,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
}
private async Task ClearPlaylistImageCacheAsync(string playlistName)
{
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
if (playlistConfig == null || string.IsNullOrWhiteSpace(playlistConfig.JellyfinId))
{
return;
}
var deletedCount = await _cache.DeleteByPatternAsync($"image:{playlistConfig.JellyfinId}:*");
_logger.LogDebug("Cleared {Count} cached local image entries for playlist {Playlist}",
deletedCount,
playlistName);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
@@ -295,6 +295,7 @@ public class SpotifyTrackMatchingService : BackgroundService
throw;
}
await ClearPlaylistImageCacheAsync(playlist);
_logger.LogInformation("✓ Rebuild complete for {Playlist}", playlistName);
}
@@ -337,6 +338,8 @@ public class SpotifyTrackMatchingService : BackgroundService
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
}
await ClearPlaylistImageCacheAsync(playlist);
}
catch (Exception ex)
{
@@ -345,6 +348,19 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
private async Task ClearPlaylistImageCacheAsync(SpotifyPlaylistConfig playlist)
{
if (string.IsNullOrWhiteSpace(playlist.JellyfinId))
{
return;
}
var deletedCount = await _cache.DeleteByPatternAsync($"image:{playlist.JellyfinId}:*");
_logger.LogDebug("Cleared {Count} cached local image entries for playlist {Playlist}",
deletedCount,
playlist.Name);
}
/// <summary>
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
/// This clears caches, fetches fresh data, and re-matches everything immediately.