Files
jellyfin-multilang/src/Jellyfin.Plugin.Multilang/Services/Providers/ProviderHttp.cs
T
ajp_anton 096c6be8c7 Release 0.3.4: Jellyfin 12 stable support and audit fixes
Harden proxy permissions and cache behavior, streamline metadata fetching, remove obsolete rules, and validate the stable SDK with expanded regression coverage. Document backup format changes.
2026-09-09 01:18:43 +00:00

146 lines
5.3 KiB
C#

using System.Net;
namespace Jellyfin.Plugin.Multilang.Services.Providers;
public enum ProviderFailureKind
{
Retryable,
Terminal
}
public sealed record ProviderHttpDiagnostics(
ProviderHttpCount[] Counts,
ProviderHttpRecent[] Recent);
public sealed record ProviderHttpCount(
string Provider,
string Outcome,
int Count);
public sealed record ProviderHttpRecent(
string Provider,
string Url,
string Outcome,
int Attempt,
long At);
public static class ProviderHttp
{
private static readonly object DiagnosticsLock = new();
private static readonly Dictionary<string, int> Counts = new(StringComparer.OrdinalIgnoreCase);
private static readonly Queue<ProviderHttpRecent> Recent = new();
public static ProviderFailureKind Classify(HttpStatusCode code)
=> code is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout ||
(int)code >= 500
? ProviderFailureKind.Retryable
: ProviderFailureKind.Terminal;
public static async Task<HttpResponseMessage> SendWithRetryAsync(
HttpClient http,
HttpRequestMessage request,
CancellationToken cancellationToken,
FetchRateLimiter? rateLimiter = null)
{
const int maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
if (rateLimiter is not null)
await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
using var attemptRequest = Clone(request);
var response = await http.SendAsync(attemptRequest, cancellationToken).ConfigureAwait(false);
Record(request, "HTTP " + (int)response.StatusCode, attempt);
if (response.IsSuccessStatusCode || Classify(response.StatusCode) == ProviderFailureKind.Terminal || attempt == maxAttempts)
{
return response;
}
var delay = GetRetryDelay(response, attempt);
response.Dispose();
await Task.Delay(delay < TimeSpan.Zero ? TimeSpan.Zero : delay, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested && ex is HttpRequestException or OperationCanceledException)
{
Record(request, ex.GetType().Name, attempt);
if (attempt >= maxAttempts)
{
throw;
}
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken).ConfigureAwait(false);
}
}
throw new HttpRequestException("Provider request failed after retries.");
}
public static ProviderHttpDiagnostics GetDiagnostics()
{
lock (DiagnosticsLock)
{
return new ProviderHttpDiagnostics(
Counts
.Select(kvp =>
{
var index = kvp.Key.IndexOf('|', StringComparison.Ordinal);
var provider = index >= 0 ? kvp.Key[..index] : kvp.Key;
var outcome = index >= 0 ? kvp.Key[(index + 1)..] : string.Empty;
return new ProviderHttpCount(provider, outcome, kvp.Value);
})
.OrderBy(c => c.Provider)
.ThenBy(c => c.Outcome)
.ToArray(),
Recent.Reverse().ToArray());
}
}
private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt)
=> response.Headers.RetryAfter?.Delta ??
(response.Headers.RetryAfter?.Date is { } date
? date - DateTimeOffset.UtcNow
: TimeSpan.FromMilliseconds(250 * attempt));
private static void Record(HttpRequestMessage request, string outcome, int attempt)
{
var provider = ProviderName(request.RequestUri);
var key = provider + "|" + outcome;
var recent = new ProviderHttpRecent(
provider,
SafeUrl(request.RequestUri),
outcome,
attempt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds());
lock (DiagnosticsLock)
{
Counts[key] = Counts.TryGetValue(key, out var count) ? count + 1 : 1;
Recent.Enqueue(recent);
while (Recent.Count > 50)
Recent.Dequeue();
}
}
private static string ProviderName(Uri? uri)
{
var host = uri?.Host ?? string.Empty;
if (host.Contains("themoviedb", StringComparison.OrdinalIgnoreCase) || host.Contains("tmdb", StringComparison.OrdinalIgnoreCase))
return "TMDb";
if (host.Contains("fanart", StringComparison.OrdinalIgnoreCase))
return "Fanart.tv";
return string.IsNullOrWhiteSpace(host) ? "unknown" : host;
}
private static string SafeUrl(Uri? uri)
=> uri is null ? string.Empty : uri.GetLeftPart(UriPartial.Path);
private static HttpRequestMessage Clone(HttpRequestMessage request)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
foreach (var header in request.Headers)
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
return clone;
}
}