From fc78a095a98549059f49e42e8a1f1458fc1fee2d Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Mon, 2 Feb 2026 02:19:43 -0500 Subject: [PATCH] Websocket Proxying --- allstarr/Controllers/JellyfinController.cs | 25 ++- .../Middleware/WebSocketProxyMiddleware.cs | 209 ++++++++++++++++++ allstarr/Program.cs | 9 + 3 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 allstarr/Middleware/WebSocketProxyMiddleware.cs diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 3e9fe10..a082c53 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -975,6 +975,24 @@ public class JellyfinController : ControllerBase var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); + // For local tracks, check if Jellyfin already has embedded lyrics + if (!isExternal) + { + _logger.LogInformation("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId); + + // Try to get lyrics from Jellyfin first (it reads embedded lyrics from files) + var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers); + + if (jellyfinLyrics != null && statusCode == 200) + { + _logger.LogInformation("✓ Found embedded lyrics in Jellyfin for track {ItemId}", itemId); + return new JsonResult(JsonSerializer.Deserialize(jellyfinLyrics.RootElement.GetRawText())); + } + + _logger.LogInformation("No embedded lyrics found in Jellyfin, falling back to LRCLIB search"); + } + + // For external tracks or when Jellyfin doesn't have lyrics, search LRCLIB Song? song = null; if (isExternal) @@ -1004,6 +1022,7 @@ public class JellyfinController : ControllerBase } // Try to get lyrics from LRCLIB + _logger.LogInformation("Searching LRCLIB for lyrics: {Artist} - {Title}", song.Artist, song.Title); var lyricsService = HttpContext.RequestServices.GetService(); if (lyricsService == null) { @@ -1668,7 +1687,9 @@ public class JellyfinController : ControllerBase } Request.Body.Position = 0; - _logger.LogInformation("📻 Playback START reported"); + _logger.LogInformation("📻 Playback START reported - Body: {Body}", body); + _logger.LogInformation("Auth headers: {Headers}", + string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase)).Select(h => $"{h.Key}={h.Value}"))); // Parse the body to check if it's an external track var doc = JsonDocument.Parse(body); @@ -1712,7 +1733,7 @@ public class JellyfinController : ControllerBase } else { - _logger.LogWarning("Playback start forward failed with status {StatusCode}", statusCode); + _logger.LogWarning("⚠️ Playback start forward returned status {StatusCode}", statusCode); } return NoContent(); diff --git a/allstarr/Middleware/WebSocketProxyMiddleware.cs b/allstarr/Middleware/WebSocketProxyMiddleware.cs new file mode 100644 index 0000000..fdcde9c --- /dev/null +++ b/allstarr/Middleware/WebSocketProxyMiddleware.cs @@ -0,0 +1,209 @@ +using System.Net.WebSockets; +using Microsoft.Extensions.Options; +using allstarr.Models.Settings; + +namespace allstarr.Middleware; + +/// +/// Middleware that proxies WebSocket connections to Jellyfin server. +/// This enables real-time features like session tracking, remote control, and live updates. +/// +public class WebSocketProxyMiddleware +{ + private readonly RequestDelegate _next; + private readonly JellyfinSettings _settings; + private readonly ILogger _logger; + + public WebSocketProxyMiddleware( + RequestDelegate next, + IOptions settings, + ILogger logger) + { + _next = next; + _settings = settings.Value; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + // Check if this is a WebSocket request to /socket + if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) && + context.WebSockets.IsWebSocketRequest) + { + _logger.LogInformation("🔌 WebSocket connection request received from {RemoteIp}", + context.Connection.RemoteIpAddress); + + await HandleWebSocketProxyAsync(context); + return; + } + + // Not a WebSocket request, pass to next middleware + await _next(context); + } + + private async Task HandleWebSocketProxyAsync(HttpContext context) + { + ClientWebSocket? serverWebSocket = null; + WebSocket? clientWebSocket = null; + + try + { + // Accept the WebSocket connection from the client + clientWebSocket = await context.WebSockets.AcceptWebSocketAsync(); + _logger.LogInformation("✓ Client WebSocket accepted"); + + // Build Jellyfin WebSocket URL + var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? ""; + var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://"; + var jellyfinHost = jellyfinUrl.Replace("https://", "").Replace("http://", ""); + var jellyfinWsUrl = $"{wsScheme}{jellyfinHost}/socket"; + + // Add query parameters if present (e.g., ?api_key=xxx or ?deviceId=xxx) + if (context.Request.QueryString.HasValue) + { + jellyfinWsUrl += context.Request.QueryString.Value; + } + + _logger.LogInformation("Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl); + + // Connect to Jellyfin WebSocket + serverWebSocket = new ClientWebSocket(); + + // Forward authentication headers + if (context.Request.Headers.TryGetValue("Authorization", out var authHeader)) + { + serverWebSocket.Options.SetRequestHeader("Authorization", authHeader.ToString()); + _logger.LogDebug("Forwarded Authorization header"); + } + else if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader)) + { + serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString()); + _logger.LogDebug("Forwarded X-Emby-Authorization header"); + } + + // Set user agent + serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0"); + + await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted); + _logger.LogInformation("✓ Connected to Jellyfin WebSocket"); + + // Start bidirectional proxying + var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted); + var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted); + + // Wait for either direction to complete + await Task.WhenAny(clientToServer, serverToClient); + + _logger.LogInformation("WebSocket proxy connection closed"); + } + catch (WebSocketException wsEx) + { + _logger.LogWarning(wsEx, "WebSocket error: {Message}", wsEx.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in WebSocket proxy"); + } + finally + { + // Clean up connections + if (clientWebSocket?.State == WebSocketState.Open) + { + try + { + await clientWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Proxy closing", CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error closing client WebSocket"); + } + } + + if (serverWebSocket?.State == WebSocketState.Open) + { + try + { + await serverWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Proxy closing", CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error closing server WebSocket"); + } + } + + clientWebSocket?.Dispose(); + serverWebSocket?.Dispose(); + + _logger.LogInformation("WebSocket connections cleaned up"); + } + } + + private async Task ProxyMessagesAsync( + WebSocket source, + WebSocket destination, + string direction, + CancellationToken cancellationToken) + { + var buffer = new byte[1024 * 4]; // 4KB buffer + var messageBuffer = new List(); + + try + { + while (source.State == WebSocketState.Open && destination.State == WebSocketState.Open) + { + var result = await source.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + + if (result.MessageType == WebSocketMessageType.Close) + { + _logger.LogInformation("{Direction}: Close message received", direction); + await destination.CloseAsync( + result.CloseStatus ?? WebSocketCloseStatus.NormalClosure, + result.CloseStatusDescription, + cancellationToken); + break; + } + + // Accumulate message fragments + messageBuffer.AddRange(buffer.Take(result.Count)); + + // If this is the end of the message, forward it + if (result.EndOfMessage) + { + var messageBytes = messageBuffer.ToArray(); + + // Log message for debugging (only in debug mode to avoid spam) + if (_logger.IsEnabled(LogLevel.Debug)) + { + var messageText = System.Text.Encoding.UTF8.GetString(messageBytes); + _logger.LogDebug("{Direction}: {MessageType} message ({Size} bytes): {Preview}", + direction, + result.MessageType, + messageBytes.Length, + messageText.Length > 200 ? messageText[..200] + "..." : messageText); + } + + // Forward the complete message + await destination.SendAsync( + new ArraySegment(messageBytes), + result.MessageType, + true, + cancellationToken); + + messageBuffer.Clear(); + } + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("{Direction}: Operation cancelled", direction); + } + catch (WebSocketException wsEx) when (wsEx.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) + { + _logger.LogInformation("{Direction}: Connection closed prematurely", direction); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "{Direction}: Error proxying messages", direction); + } + } +} diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 44c1684..7586869 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -288,6 +288,15 @@ app.UseExceptionHandler(_ => { }); // Global exception handler // Enable response compression EARLY in the pipeline app.UseResponseCompression(); +// Enable WebSocket support +app.UseWebSockets(new WebSocketOptions +{ + KeepAliveInterval = TimeSpan.FromSeconds(120) +}); + +// Add WebSocket proxy middleware (BEFORE routing) +app.UseMiddleware(); + if (app.Environment.IsDevelopment()) { app.UseSwagger();