Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user