Initial jellyfin-multilang rewrite

This commit is contained in:
ajp_anton
2026-05-31 18:28:04 +00:00
commit 63dbc3a6d4
32 changed files with 9326 additions and 0 deletions
@@ -0,0 +1,141 @@
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)
{
const int maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
var response = await http.SendAsync(Clone(request), cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode || Classify(response.StatusCode) == ProviderFailureKind.Terminal || attempt == maxAttempts)
{
Record(request, "HTTP " + (int)response.StatusCode, attempt);
return response;
}
var delay = GetRetryDelay(response, attempt);
response.Dispose();
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
if (attempt >= maxAttempts)
{
Record(request, ex.GetType().Name, attempt);
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;
}
}