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.
146 lines
5.3 KiB
C#
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;
|
|
}
|
|
}
|