Files
allstarr/allstarr/Services/Subsonic/SubsonicProxyService.cs
Josh Patra 2b09484c0b Release v1.0.0 - Production Ready
Major Features:
- Spotify playlist injection with missing tracks search
- Transparent proxy authentication system
- WebSocket session management for external tracks
- Manual track mapping and favorites system
- Lyrics support (Spotify + LRCLib) with prefetching
- Admin dashboard with analytics and configuration
- Performance optimizations with health checks and endpoint racing
- Comprehensive caching and memory management

Performance Improvements:
- Quick health checks (3s timeout) before trying endpoints
- Health check results cached for 30 seconds
- 5 minute timeout for large artist responses
- Background Odesli conversion after streaming starts
- Parallel lyrics prefetching
- Endpoint benchmarking and racing
- 16 SquidWTF endpoints with load balancing

Reliability:
- Automatic endpoint fallback and failover
- Token expiration handling
- Concurrent request optimization
- Memory leak fixes
- Proper session cleanup

User Experience:
- Web UI for configuration and playlist management
- Real-time progress tracking
- API analytics dashboard
- Manual track mapping interface
- Playlist statistics and health monitoring
2026-02-08 00:43:47 -05:00

157 lines
5.5 KiB
C#

using Microsoft.AspNetCore.Mvc;
using allstarr.Models.Settings;
namespace allstarr.Services.Subsonic;
/// <summary>
/// Handles proxying requests to the underlying Subsonic server.
/// </summary>
public class SubsonicProxyService
{
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _subsonicSettings;
private readonly IHttpContextAccessor _httpContextAccessor;
public SubsonicProxyService(
IHttpClientFactory httpClientFactory,
Microsoft.Extensions.Options.IOptions<SubsonicSettings> subsonicSettings,
IHttpContextAccessor httpContextAccessor)
{
_httpClient = httpClientFactory.CreateClient();
_subsonicSettings = subsonicSettings.Value;
_httpContextAccessor = httpContextAccessor;
}
/// <summary>
/// Relays a request to the Subsonic server and returns the response.
/// </summary>
public async Task<(byte[] Body, string? ContentType)> RelayAsync(
string endpoint,
Dictionary<string, string> parameters)
{
var query = string.Join("&", parameters.Select(kv =>
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{_subsonicSettings.Url}/{endpoint}?{query}";
HttpResponseMessage response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString();
// Trigger GC for large files to prevent memory leaks
if (body.Length > 1024 * 1024) // 1MB threshold
{
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
}
return (body, contentType);
}
/// <summary>
/// Safely relays a request to the Subsonic server, returning null on failure.
/// </summary>
public async Task<(byte[]? Body, string? ContentType, bool Success)> RelaySafeAsync(
string endpoint,
Dictionary<string, string> parameters)
{
try
{
var result = await RelayAsync(endpoint, parameters);
return (result.Body, result.ContentType, true);
}
catch
{
return (null, null, false);
}
}
private static readonly string[] StreamingRequiredHeaders =
{
"Accept-Ranges",
"Content-Range",
"Content-Length",
"ETag",
"Last-Modified"
};
/// <summary>
/// Relays a stream request to the Subsonic server with range processing support.
/// </summary>
public async Task<IActionResult> RelayStreamAsync(
Dictionary<string, string> parameters,
CancellationToken cancellationToken)
{
try
{
// Get HTTP context for request/response forwarding
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null)
{
return new ObjectResult(new { error = "HTTP context not available" })
{
StatusCode = 500
};
}
var incomingRequest = httpContext.Request;
var outgoingResponse = httpContext.Response;
var query = string.Join("&", parameters.Select(kv =>
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{_subsonicSettings.Url}/rest/stream?{query}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Forward Range headers for progressive streaming support (iOS clients)
if (incomingRequest.Headers.TryGetValue("Range", out var range))
{
request.Headers.TryAddWithoutValidation("Range", range.ToArray());
}
if (incomingRequest.Headers.TryGetValue("If-Range", out var ifRange))
{
request.Headers.TryAddWithoutValidation("If-Range", ifRange.ToArray());
}
var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new StatusCodeResult((int)response.StatusCode);
}
// Forward HTTP status code (e.g., 206 Partial Content for range requests)
outgoingResponse.StatusCode = (int)response.StatusCode;
// Forward streaming-required headers from upstream response
foreach (var header in StreamingRequiredHeaders)
{
if (response.Headers.TryGetValues(header, out var values) ||
response.Content.Headers.TryGetValues(header, out values))
{
outgoingResponse.Headers[header] = values.ToArray();
}
}
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
return new FileStreamResult(stream, contentType)
{
EnableRangeProcessing = true
};
}
catch (Exception ex)
{
return new ObjectResult(new { error = $"Error streaming from Subsonic: {ex.Message}" })
{
StatusCode = 500
};
}
}
}