Files
lampac/mods/Music/Services/Playback/MusicStreamRelayService.cs
T

283 lines
10 KiB
C#

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
namespace Music;
public static class MusicStreamRelayService
{
static readonly HttpClient musicStreamClient = CreateMusicStreamClient();
static readonly System.Collections.Concurrent.ConcurrentDictionary<string, ProxyClientEntry> proxyClients = new();
static readonly TimeSpan proxyClientIdleTtl = TimeSpan.FromMinutes(30);
const int maxProxyClients = 128;
static readonly Timer proxyClientCleanupTimer = new(
_ => CleanupProxyClients(),
null,
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(5)
);
sealed class ProxyClientEntry
{
public ProxyClientEntry(HttpClient client)
{
Client = client;
LastUsedUtc = DateTime.UtcNow;
}
public readonly object Sync = new();
public HttpClient Client { get; }
public DateTime LastUsedUtc { get; set; }
public int ActiveRequests { get; set; }
public bool Retired { get; set; }
}
sealed class ClientLease : IDisposable
{
ProxyClientEntry entry;
public ClientLease(HttpClient client, ProxyClientEntry entry = null)
{
Client = client;
this.entry = entry;
}
public HttpClient Client { get; }
public void Dispose()
{
var current = Interlocked.Exchange(ref entry, null);
if (current == null)
return;
lock (current.Sync)
{
current.ActiveRequests = Math.Max(0, current.ActiveRequests - 1);
current.LastUsedUtc = DateTime.UtcNow;
}
}
}
// failOnUpstreamError: не транслировать клиенту мёртвую ссылку или ошибку
// прокси, а вернуть null — вызывающий пере-резолвит источник и маршрут.
public static async Task<ActionResult> RelayAsync(HttpContext httpContext, MusicPlaybackSource source, bool failOnUpstreamError = false)
{
if (httpContext == null || source == null || string.IsNullOrWhiteSpace(source.url))
return failOnUpstreamError ? null : new StatusCodeResult(404);
using var request = new HttpRequestMessage(HttpMethod.Get, source.url);
if (source.headers != null)
{
foreach (var header in source.headers)
{
if (string.IsNullOrWhiteSpace(header.Key) || string.IsNullOrWhiteSpace(header.Value))
continue;
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
if (httpContext.Request.Headers.TryGetValue("Range", out var range) && !string.IsNullOrWhiteSpace(range.ToString()))
request.Headers.TryAddWithoutValidation("Range", range.ToString());
using var clientLease = AcquireClient(source);
HttpClient client = clientLease.Client;
HttpResponseMessage response;
try
{
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, httpContext.RequestAborted);
}
catch (OperationCanceledException) when (httpContext.RequestAborted.IsCancellationRequested)
{
return new EmptyResult();
}
catch (Exception ex) when (failOnUpstreamError)
{
ReportProxyFailure(source, ex);
return null;
}
catch (Exception ex)
{
ReportProxyFailure(source, ex);
throw;
}
using (response)
{
bool proxyFailure = MusicHttp.IsProxyFailureStatus(response.StatusCode);
if (proxyFailure)
MusicProxyService.ReportFailure(source.proxy_scope);
else
MusicProxyService.ReportSuccess(source.proxy_scope);
if (failOnUpstreamError && (IsDeadUpstreamStatus(response.StatusCode) || proxyFailure))
return null;
httpContext.Response.StatusCode = (int)response.StatusCode;
httpContext.Response.Headers.Remove("transfer-encoding");
CopyResponseHeader(httpContext, "Accept-Ranges", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Content-Range", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Content-Length", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Content-Type", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Cache-Control", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Expires", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Last-Modified", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "ETag", response.Headers, response.Content.Headers);
if (!string.IsNullOrWhiteSpace(source.mime_type))
httpContext.Response.ContentType = source.mime_type;
try
{
await using var stream = await response.Content.ReadAsStreamAsync(httpContext.RequestAborted);
await stream.CopyToAsync(httpContext.Response.Body, httpContext.RequestAborted);
}
catch (OperationCanceledException)
{
// клиент ушёл (перемотка/стоп/смена трека) — штатный путь
}
catch (IOException)
{
// upstream оборвал середину стрима: рвём соединение, чтобы клиент
// сделал range-retry, а не принял обрыв за конец файла
httpContext.Abort();
}
return new EmptyResult();
}
}
static bool IsDeadUpstreamStatus(HttpStatusCode statusCode)
=> statusCode is HttpStatusCode.Forbidden
or HttpStatusCode.NotFound
or HttpStatusCode.Gone
or HttpStatusCode.RequestedRangeNotSatisfiable;
static void ReportProxyFailure(MusicPlaybackSource source, Exception exception)
{
if (MusicHttp.IsProxyFailure(exception))
MusicProxyService.ReportFailure(source?.proxy_scope);
}
static ClientLease AcquireClient(MusicPlaybackSource source)
{
var proxy = CreateMusicStreamProxy(source);
if (proxy == null)
return new ClientLease(musicStreamClient);
// клиент на прокси кэшируется: без этого каждый range-запрос платит
// новый TLS-хендшейк через прокси. Lease не даёт очистке закрыть
// HttpClient, пока через него идёт активный stream/range-запрос.
string key = $"{source.proxy_url}|{source.proxy_username}|{source.proxy_password}";
while (true)
{
var entry = proxyClients.GetOrAdd(key, _ => new ProxyClientEntry(CreateProxyClient(proxy)));
lock (entry.Sync)
{
if (entry.Retired)
continue;
entry.ActiveRequests++;
entry.LastUsedUtc = DateTime.UtcNow;
return new ClientLease(entry.Client, entry);
}
}
}
static HttpClient CreateProxyClient(WebProxy proxy)
{
var handler = new HttpClientHandler
{
AllowAutoRedirect = true,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
Proxy = proxy,
UseProxy = true
};
handler.ServerCertificateCustomValidationCallback += (_, _, _, _) => true;
return new HttpClient(handler)
{
Timeout = Timeout.InfiniteTimeSpan
};
}
static void CleanupProxyClients()
{
try
{
var now = DateTime.UtcNow;
foreach (var item in proxyClients.ToArray().OrderBy(i => i.Value.LastUsedUtc))
{
bool overLimit = proxyClients.Count > maxProxyClients;
bool expired = now - item.Value.LastUsedUtc >= proxyClientIdleTtl;
if (!overLimit && !expired)
continue;
HttpClient retiredClient = null;
lock (item.Value.Sync)
{
if (item.Value.Retired || item.Value.ActiveRequests > 0)
continue;
if (proxyClients.TryRemove(item.Key, out var removed) && ReferenceEquals(removed, item.Value))
{
item.Value.Retired = true;
retiredClient = item.Value.Client;
}
}
retiredClient?.Dispose();
}
}
catch
{
// Очистка кэша не должна влиять на активные стримы.
}
}
static HttpClient CreateMusicStreamClient()
{
var handler = new HttpClientHandler
{
AllowAutoRedirect = true,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli
};
handler.ServerCertificateCustomValidationCallback += (_, _, _, _) => true;
return new HttpClient(handler)
{
Timeout = Timeout.InfiniteTimeSpan
};
}
static void CopyResponseHeader(HttpContext httpContext, string headerName, HttpResponseHeaders responseHeaders, HttpContentHeaders contentHeaders)
{
if (responseHeaders.TryGetValues(headerName, out var headerValues) || contentHeaders.TryGetValues(headerName, out headerValues))
httpContext.Response.Headers[headerName] = headerValues.ToArray();
}
static WebProxy CreateMusicStreamProxy(MusicPlaybackSource source)
{
if (source == null || string.IsNullOrWhiteSpace(source.proxy_url))
return null;
if (!Uri.TryCreate(source.proxy_url, UriKind.Absolute, out var proxyUri))
return null;
NetworkCredential credentials = null;
if (!string.IsNullOrWhiteSpace(source.proxy_username))
credentials = new NetworkCredential(source.proxy_username, source.proxy_password ?? string.Empty);
return new WebProxy(proxyUri, false, null, credentials);
}
}