perf(json): finish source-generated hot-path serialization

This commit is contained in:
2026-04-05 13:28:05 -04:00
parent 81bae5621a
commit ad6f521795
8 changed files with 141 additions and 65 deletions
@@ -1,5 +1,6 @@
using System.Reflection;
using allstarr.Controllers;
using allstarr.Models.Jellyfin;
namespace allstarr.Tests;
@@ -8,16 +9,16 @@ public class JellyfinSearchResponseSerializationTests
[Fact]
public void SerializeSearchResponseJson_PreservesPascalCaseShape()
{
var payload = new
var payload = new JellyfinItemsResponse
{
Items = new[]
{
Items =
[
new Dictionary<string, object?>
{
["Name"] = "BTS",
["Type"] = "MusicAlbum"
}
},
],
TotalRecordCount = 1,
StartIndex = 0
};
@@ -28,8 +29,7 @@ public class JellyfinSearchResponseSerializationTests
Assert.NotNull(method);
var closedMethod = method!.MakeGenericMethod(payload.GetType());
var json = (string)closedMethod.Invoke(null, new object?[] { payload })!;
var json = (string)method!.Invoke(null, new object?[] { payload })!;
Assert.Equal(
"{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}",
+15 -1
View File
@@ -52,9 +52,17 @@ public class JellyfinSessionManagerTests
public async Task RemoveSessionAsync_ReportsPlaybackStopButDoesNotLogoutUserSession()
{
var requestedPaths = new ConcurrentBag<string>();
var requestBodies = new ConcurrentDictionary<string, string>();
var handler = new DelegateHttpMessageHandler((request, _) =>
{
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
requestedPaths.Add(path);
if (request.Content != null)
{
requestBodies[path] = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
});
@@ -89,6 +97,12 @@ public class JellyfinSessionManagerTests
Assert.Contains("/Sessions/Capabilities/Full", requestedPaths);
Assert.Contains("/Sessions/Playing/Stopped", requestedPaths);
Assert.DoesNotContain("/Sessions/Logout", requestedPaths);
Assert.Equal(
"{\"PlayableMediaTypes\":[\"Audio\"],\"SupportedCommands\":[\"Play\",\"Playstate\",\"PlayNext\"],\"SupportsMediaControl\":true,\"SupportsPersistentIdentifier\":true,\"SupportsSync\":false}",
requestBodies["/Sessions/Capabilities/Full"]);
Assert.Equal(
"{\"ItemId\":\"item-123\",\"PositionTicks\":42}",
requestBodies["/Sessions/Playing/Stopped"]);
}
[Fact]
@@ -1,7 +1,9 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Globalization;
using allstarr.Models.Jellyfin;
using allstarr.Models.Scrobbling;
using allstarr.Serialization;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
@@ -226,7 +228,7 @@ public partial class JellyfinController
// Build minimal playback start with just the ghost UUID
// Don't include the Item object - Jellyfin will just track the session without item details
var playbackStart = new
var playbackStart = new JellyfinPlaybackStatePayload
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
@@ -236,7 +238,7 @@ public partial class JellyfinController
PlayMethod = "DirectPlay"
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
// Forward to Jellyfin with ghost UUID
@@ -357,14 +359,13 @@ public partial class JellyfinController
trackName ?? "Unknown", itemId);
// Build playback start info - Jellyfin will fetch item details itself
var playbackStart = new
var playbackStart = new JellyfinPlaybackStatePayload
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0,
// Let Jellyfin fetch the item details - don't include NowPlayingItem
ItemId = itemId ?? string.Empty,
PositionTicks = positionTicks ?? 0
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending playback start: {Json}", playbackJson);
var (result, statusCode) =
@@ -624,7 +625,7 @@ public partial class JellyfinController
externalId);
var inferredStartGhostUuid = GenerateUuidFromString(itemId);
var inferredExternalStartPayload = JsonSerializer.Serialize(new
var inferredExternalStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{
ItemId = inferredStartGhostUuid,
PositionTicks = positionTicks ?? 0,
@@ -692,7 +693,7 @@ public partial class JellyfinController
var ghostUuid = GenerateUuidFromString(itemId);
// Build progress report with ghost UUID
var progressReport = new
var progressReport = new JellyfinPlaybackStatePayload
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
@@ -702,7 +703,7 @@ public partial class JellyfinController
PlayMethod = "DirectPlay"
};
var progressJson = JsonSerializer.Serialize(progressReport);
var progressJson = AllstarrJsonSerializer.Serialize(progressReport);
// Forward to Jellyfin with ghost UUID
var (progressResult, progressStatusCode) =
@@ -773,7 +774,7 @@ public partial class JellyfinController
_logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})",
trackName ?? "Unknown", itemId);
var inferredStartPayload = JsonSerializer.Serialize(new
var inferredStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0
@@ -948,7 +949,7 @@ public partial class JellyfinController
}
var ghostUuid = GenerateUuidFromString(previousItemId);
var inferredExternalStopPayload = JsonSerializer.Serialize(new
var inferredExternalStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{
ItemId = ghostUuid,
PositionTicks = previousPositionTicks ?? 0,
@@ -997,7 +998,7 @@ public partial class JellyfinController
});
}
var inferredStopPayload = JsonSerializer.Serialize(new
var inferredStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
{
ItemId = previousItemId,
PositionTicks = previousPositionTicks ?? 0,
@@ -1294,13 +1295,13 @@ public partial class JellyfinController
// Report stop to Jellyfin with ghost UUID
var ghostUuid = GenerateUuidFromString(itemId);
var externalStopInfo = new
var externalStopInfo = new JellyfinPlaybackStatePayload
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0
};
var stopJson = JsonSerializer.Serialize(externalStopInfo);
var stopJson = AllstarrJsonSerializer.Serialize(externalStopInfo);
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
var (stopResult, stopStatusCode) =
@@ -1469,7 +1470,7 @@ public partial class JellyfinController
stopInfo["PositionTicks"] = positionTicks.Value;
}
body = JsonSerializer.Serialize(stopInfo);
body = AllstarrJsonSerializer.Serialize(stopInfo);
_logger.LogDebug("📤 Sending playback stop body (IsPaused=false, {BodyLength} bytes)", body.Length);
var (result, statusCode) =
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text;
using allstarr.Models.Jellyfin;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Serialization;
@@ -127,18 +128,12 @@ public partial class JellyfinController
var album = await _metadataService.GetAlbumAsync(provider!, externalId!, HttpContext.RequestAborted);
if (album == null)
{
return new JsonResult(new
{ Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
return CreateItemsResponse([], 0, startIndex);
}
var albumItems = album.Songs.Select(song => _responseBuilder.ConvertSongToJellyfinItem(song)).ToList();
return new JsonResult(new
{
Items = albumItems,
TotalRecordCount = albumItems.Count,
StartIndex = startIndex
});
return CreateItemsResponse(albumItems, albumItems.Count, startIndex);
}
// If library album, fall through to handle with ParentId or proxy
}
@@ -559,7 +554,7 @@ public partial class JellyfinController
try
{
// Return with PascalCase - use ContentResult to bypass JSON serialization issues
var response = new
var response = new JellyfinItemsResponse
{
Items = pagedItems,
TotalRecordCount = items.Count,
@@ -604,27 +599,9 @@ public partial class JellyfinController
}
}
private static string SerializeSearchResponseJson<T>(T response) where T : class
private static string SerializeSearchResponseJson(JellyfinItemsResponse response)
{
// Use source-gen context for registered types (no reflection, 3-8x faster)
try
{
var typeInfo = AllstarrJsonContext.Shared.GetTypeInfo(typeof(T));
if (typeInfo != null)
{
return JsonSerializer.Serialize(response, typeInfo);
}
}
catch
{
// Type not in source-gen context — fall through to reflection
}
return JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null
});
return AllstarrJsonSerializer.Serialize(response);
}
/// <summary>
@@ -789,12 +766,26 @@ public partial class JellyfinController
private static IActionResult CreateEmptyItemsResponse(int startIndex)
{
return new JsonResult(new
return CreateItemsResponse([], 0, startIndex);
}
private static ContentResult CreateItemsResponse(
List<Dictionary<string, object?>> items,
int totalRecordCount,
int startIndex)
{
var response = new JellyfinItemsResponse
{
Items = Array.Empty<object>(),
TotalRecordCount = 0,
Items = items,
TotalRecordCount = totalRecordCount,
StartIndex = startIndex
});
};
return new ContentResult
{
Content = SerializeSearchResponseJson(response),
ContentType = "application/json"
};
}
/// <summary>
@@ -0,0 +1,37 @@
namespace allstarr.Models.Jellyfin;
/// <summary>
/// Canonical Jellyfin Items response wrapper used by search-related hot paths.
/// </summary>
public class JellyfinItemsResponse
{
public List<Dictionary<string, object?>> Items { get; set; } = [];
public int TotalRecordCount { get; set; }
public int StartIndex { get; set; }
}
/// <summary>
/// Playback payload forwarded to Jellyfin for start/progress/stop events.
/// Nullable members are omitted to preserve the lean request shapes clients expect.
/// </summary>
public class JellyfinPlaybackStatePayload
{
public string ItemId { get; set; } = string.Empty;
public long PositionTicks { get; set; }
public bool? CanSeek { get; set; }
public bool? IsPaused { get; set; }
public bool? IsMuted { get; set; }
public string? PlayMethod { get; set; }
}
/// <summary>
/// Synthetic capabilities payload used when allstarr needs to establish a Jellyfin session.
/// </summary>
public class JellyfinSessionCapabilitiesPayload
{
public string[] PlayableMediaTypes { get; set; } = [];
public string[] SupportedCommands { get; set; } = [];
public bool SupportsMediaControl { get; set; }
public bool SupportsPersistentIdentifier { get; set; }
public bool SupportsSync { get; set; }
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using allstarr.Models.Domain;
using allstarr.Models.Jellyfin;
using allstarr.Models.Lyrics;
using allstarr.Models.Search;
using allstarr.Models.Spotify;
@@ -25,6 +26,9 @@ namespace allstarr.Serialization;
[JsonSerializable(typeof(Album))]
[JsonSerializable(typeof(Artist))]
[JsonSerializable(typeof(SearchResult))]
[JsonSerializable(typeof(JellyfinItemsResponse))]
[JsonSerializable(typeof(JellyfinPlaybackStatePayload))]
[JsonSerializable(typeof(JellyfinSessionCapabilitiesPayload))]
[JsonSerializable(typeof(List<Song>))]
[JsonSerializable(typeof(List<Album>))]
[JsonSerializable(typeof(List<Artist>))]
@@ -43,6 +47,7 @@ namespace allstarr.Serialization;
// Collection types used in cache and playlist items
[JsonSerializable(typeof(List<Dictionary<string, object?>>))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(byte[]))]
@@ -0,0 +1,27 @@
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
namespace allstarr.Serialization;
internal static class AllstarrJsonSerializer
{
public static string Serialize<T>(T value)
{
var typeInfo = GetTypeInfo<T>();
return typeInfo != null
? JsonSerializer.Serialize(value, typeInfo)
: JsonSerializer.Serialize(value);
}
private static JsonTypeInfo<T>? GetTypeInfo<T>()
{
try
{
return (JsonTypeInfo<T>?)AllstarrJsonContext.Shared.GetTypeInfo(typeof(T));
}
catch
{
return null;
}
}
}
@@ -1,10 +1,11 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using allstarr.Models.Jellyfin;
using allstarr.Models.Settings;
using allstarr.Serialization;
namespace allstarr.Services.Jellyfin;
@@ -185,21 +186,21 @@ public class JellyfinSessionManager : IDisposable
/// </summary>
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
{
var capabilities = new
var capabilities = new JellyfinSessionCapabilitiesPayload
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = new[]
{
PlayableMediaTypes = ["Audio"],
SupportedCommands =
[
"Play",
"Playstate",
"PlayNext"
},
],
SupportsMediaControl = true,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var json = JsonSerializer.Serialize(capabilities);
var json = AllstarrJsonSerializer.Serialize(capabilities);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
if (statusCode == 204 || statusCode == 200)
@@ -455,12 +456,12 @@ public class JellyfinSessionManager : IDisposable
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
{
var stopPayload = new
var stopPayload = new JellyfinPlaybackStatePayload
{
ItemId = session.LastPlayingItemId,
PositionTicks = session.LastPlayingPositionTicks ?? 0
};
var stopJson = JsonSerializer.Serialize(stopPayload);
var stopJson = AllstarrJsonSerializer.Serialize(stopPayload);
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);