283 lines
10 KiB
C#
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);
|
|
}
|
|
}
|