mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 03:53:10 -04:00
perf(json): finish source-generated hot-path serialization
This commit is contained in:
@@ -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}",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user