fix search serialization and warm playlist matching
CI / build-and-test (push) Has been cancelled

This commit is contained in:
2026-04-06 03:51:31 -04:00
parent 885c86358d
commit 579c1e04d8
5 changed files with 108 additions and 18 deletions
@@ -1,4 +1,5 @@
using System.Reflection;
using System.Text.Json;
using allstarr.Controllers;
using allstarr.Models.Jellyfin;
@@ -35,4 +36,58 @@ public class JellyfinSearchResponseSerializationTests
"{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}",
json);
}
[Fact]
public void SerializeSearchResponseJson_FallsBackForMixedRuntimeShapes()
{
using var rawDoc = JsonDocument.Parse("""
{
"ServerId": "c17d351d3af24c678a6d8049c212d522",
"RunTimeTicks": 2234068710
}
""");
var payload = new JellyfinItemsResponse
{
Items =
[
new Dictionary<string, object?>
{
["Name"] = "Harleys in Hawaii",
["Type"] = "MusicAlbum",
["MediaSources"] = new Dictionary<string, object?>[]
{
new Dictionary<string, object?>
{
["RunTimeTicks"] = 2234068710L,
["MediaAttachments"] = new List<object>(),
["Formats"] = new List<string>(),
["RequiredHttpHeaders"] = new Dictionary<string, string>()
}
},
["ArtistItems"] = new List<object>
{
new Dictionary<string, object?> { ["Name"] = "Katy Perry" }
},
["RawItem"] = rawDoc.RootElement.Clone()
}
],
TotalRecordCount = 1,
StartIndex = 0
};
var method = typeof(JellyfinController).GetMethod(
"SerializeSearchResponseJson",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
var json = (string)method!.Invoke(null, new object?[] { payload })!;
Assert.Contains("\"Items\":[{", json);
Assert.Contains("\"MediaAttachments\":[]", json);
Assert.Contains("\"ArtistItems\":[{\"Name\":\"Katy Perry\"}]", json);
Assert.Contains("\"RawItem\":{\"ServerId\":\"c17d351d3af24c678a6d8049c212d522\",\"RunTimeTicks\":2234068710}", json);
Assert.Contains("\"TotalRecordCount\":1", json);
}
}
@@ -595,23 +595,13 @@ public partial class JellyfinController
!string.IsNullOrWhiteSpace(searchCacheKey) &&
externalHasRequestedTypeResults;
ArrayBufferWriter<byte>? cacheCapture = shouldCache ? new ArrayBufferWriter<byte>() : null;
Response.StatusCode = StatusCodes.Status200OK;
Response.ContentType = "application/json";
var json = SerializeSearchResponseJson(response);
await Response.WriteAsync(json, Encoding.UTF8);
var bufferWriter = new TeeBufferWriter(Response.BodyWriter, cacheCapture);
using (var writer = new Utf8JsonWriter(bufferWriter))
if (shouldCache)
{
JsonSerializer.Serialize(writer, response, AllstarrJsonContext.Shared.JellyfinItemsResponse);
writer.Flush();
}
await Response.BodyWriter.FlushAsync();
if (shouldCache && cacheCapture != null)
{
var json = Encoding.UTF8.GetString(cacheCapture.WrittenSpan);
await _cache.SetStringAsync(searchCacheKey!, json, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
CacheExtensions.SearchResultsTTL.TotalMinutes);
@@ -165,9 +165,31 @@ public partial class JellyfinController
if (orderedTracks == null || orderedTracks.Count == 0)
{
_logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
_logger.LogInformation(
"No ordered matched tracks in cache for {Playlist}; attempting on-demand matching before fallback",
spotifyPlaylistName);
return null; // Fall back to legacy mode
if (_spotifyTrackMatchingService != null)
{
try
{
await _spotifyTrackMatchingService.TriggerMatchingForPlaylistAsync(spotifyPlaylistName);
orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"On-demand matching failed for {Playlist}; falling back to passthrough playlist response",
spotifyPlaylistName);
}
}
if (orderedTracks == null || orderedTracks.Count == 0)
{
_logger.LogInformation("Ordered matched tracks are still unavailable for {Playlist}", spotifyPlaylistName);
return null; // Fall back to legacy mode
}
}
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
@@ -45,6 +45,7 @@ public partial class JellyfinController : ControllerBase
private readonly JellyfinSessionManager _sessionManager;
private readonly PlaylistSyncService? _playlistSyncService;
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
private readonly SpotifyTrackMatchingService? _spotifyTrackMatchingService;
private readonly SpotifyLyricsService? _spotifyLyricsService;
private readonly LyricsPlusService? _lyricsPlusService;
private readonly LrclibService? _lrclibService;
@@ -77,6 +78,7 @@ public partial class JellyfinController : ControllerBase
ParallelMetadataService? parallelMetadataService = null,
PlaylistSyncService? playlistSyncService = null,
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
SpotifyTrackMatchingService? spotifyTrackMatchingService = null,
SpotifyLyricsService? spotifyLyricsService = null,
LyricsPlusService? lyricsPlusService = null,
LrclibService? lrclibService = null,
@@ -100,6 +102,7 @@ public partial class JellyfinController : ControllerBase
_sessionManager = sessionManager;
_playlistSyncService = playlistSyncService;
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
_spotifyTrackMatchingService = spotifyTrackMatchingService;
_spotifyLyricsService = spotifyLyricsService;
_lyricsPlusService = lyricsPlusService;
_lrclibService = lrclibService;
@@ -1,16 +1,36 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace allstarr.Serialization;
internal static class AllstarrJsonSerializer
{
private static readonly JsonSerializerOptions ReflectionFallbackOptions = new()
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
public static string Serialize<T>(T value)
{
var typeInfo = GetTypeInfo<T>();
return typeInfo != null
? JsonSerializer.Serialize(value, typeInfo)
: JsonSerializer.Serialize(value);
if (typeInfo != null)
{
try
{
return JsonSerializer.Serialize(value, typeInfo);
}
catch (NotSupportedException)
{
// Mixed Jellyfin payloads often carry runtime-only shapes such as JsonElement,
// List<object>, or dictionary arrays. Fall back to reflection for those cases.
}
}
return JsonSerializer.Serialize(value, ReflectionFallbackOptions);
}
private static JsonTypeInfo<T>? GetTypeInfo<T>()