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 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 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); } }