using System; using System.Diagnostics; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Lyrics; /// /// Process-wide rate-limit gate for lrclib.net requests. Enforces a minimum gap between outbound /// calls and honors and /// together with the Retry-After header so a /// transient spike doesn't get logged as a generic per-track error. /// internal static class LrclibRateLimiter { private const int MaxThrottleRetries = 1; private const string OfficialHost = "lrclib.net"; private const string OfficialHostSuffix = "." + OfficialHost; private static readonly TimeSpan MinimumGap = TimeSpan.FromMilliseconds(200); private static readonly TimeSpan MinimumDelay = TimeSpan.FromMilliseconds(1); private static readonly TimeSpan MaxRetryAfter = TimeSpan.FromMinutes(10); private static readonly TimeSpan FallbackRetryAfter = TimeSpan.FromSeconds(5); private static readonly SemaphoreSlim _gate = new(1, 1); private static readonly ProductInfoHeaderValue _uaProduct = new("jellyfin-plugin-lyrics", ResolveVersion()); private static readonly ProductInfoHeaderValue _uaComment = new("(+https://github.com/Felitendo/jellyfin-plugin-lyrics)"); // Stopwatch rather than DateTime.UtcNow: a wall-clock jump backwards (NTP correction, VM // resume, a container starting with a bad clock) would otherwise leave the watermark in the // future and stall every request for the length of the jump. private static long _nextRequestTimestamp = Stopwatch.GetTimestamp(); private static long _throttledResponseCount; private static int _consecutiveThrottledResponses; /// /// Gets the number of requests that were still throttled after their retry. Jellyfin's lyric /// manager swallows provider errors, so the lyrics task compares two readings of this to tell /// an empty result from a throttled one. /// public static long ThrottledResponseCount => Interlocked.Read(ref _throttledResponseCount); /// /// Gets the number of requests in a row that were still throttled after their retry. Any /// other response resets it. /// public static int ConsecutiveThrottledResponses => Volatile.Read(ref _consecutiveThrottledResponses); /// /// Gets a value indicating whether the inter-request gap is skipped. Only honored against a /// self-hosted instance; requests to the public lrclib.net are always rate limited. /// private static bool IsRateLimitDisabled { get { var configuration = LyricsPlugin.Instance?.Configuration; return configuration is not null && configuration.DisableRateLimit && !IsOfficialServer(configuration.LrclibBaseUrl); } } /// /// Sends a request to lrclib.net, waiting if necessary to respect the inter-request gap and /// any prior Retry-After backoff. On a 429 or 503 response, parses Retry-After, bumps the /// watermark, and retries once before returning to the caller. /// /// HTTP client to send the request on. /// Factory that builds a fresh per attempt; needed because messages can't be re-sent. /// Logger for backoff diagnostics. /// Cancellation token. /// The final response; caller is responsible for disposal and EnsureSuccessStatusCode. public static async Task SendAsync( HttpClient client, Func requestFactory, ILogger logger, CancellationToken cancellationToken) { if (IsRateLimitDisabled) { var unlimitedRequest = requestFactory(); ApplyUserAgent(unlimitedRequest); return await client .SendAsync(unlimitedRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); } for (var attempt = 0; ; attempt++) { await WaitForSlotAsync(cancellationToken).ConfigureAwait(false); var request = requestFactory(); ApplyUserAgent(request); var response = await client .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); if (!IsThrottleResponse(response.StatusCode)) { Volatile.Write(ref _consecutiveThrottledResponses, 0); return response; } var retryAfter = ResolveRetryAfter(response.Headers.RetryAfter); // Apply the delay before the final return as well. Returning the last 429/503 without // moving the watermark lets the next track fire after only the normal gap, which // ignores the server's Retry-After instruction entirely. try { await BumpWatermarkAsync(retryAfter, cancellationToken).ConfigureAwait(false); } catch { // Cancellation while waiting for the gate leaves the caller without a response, // so release this one here; successful calls keep caller ownership. response.Dispose(); throw; } if (attempt >= MaxThrottleRetries) { Interlocked.Increment(ref _throttledResponseCount); Interlocked.Increment(ref _consecutiveThrottledResponses); logger.LogWarning( "LRCLIB returned {StatusCode} after {Attempts} attempt(s); next request in at least {Delay}.", (int)response.StatusCode, attempt + 1, retryAfter); return response; } logger.LogWarning( "LRCLIB returned {StatusCode}, backing off for {Delay} before retrying.", (int)response.StatusCode, retryAfter); response.Dispose(); } } private static bool IsThrottleResponse(HttpStatusCode statusCode) => statusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable; private static async Task WaitForSlotAsync(CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Task.Delay may fire fractionally early, so re-check rather than assuming a single // delay was enough to clear the gap. for (var remaining = GetRemainingGap(); remaining > TimeSpan.Zero; remaining = GetRemainingGap()) { await Task.Delay(remaining < MinimumDelay ? MinimumDelay : remaining, cancellationToken).ConfigureAwait(false); } DelayNextRequest(MinimumGap); } finally { _gate.Release(); } } private static async Task BumpWatermarkAsync(TimeSpan delay, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { DelayNextRequest(delay); } finally { _gate.Release(); } } private static void DelayNextRequest(TimeSpan delay) { var timestamp = Stopwatch.GetTimestamp() + (long)(Stopwatch.Frequency * delay.TotalSeconds); if (timestamp > _nextRequestTimestamp) { _nextRequestTimestamp = timestamp; } } private static TimeSpan GetRemainingGap() => Stopwatch.GetElapsedTime(Stopwatch.GetTimestamp(), _nextRequestTimestamp); // A missing, unparsable or non-http(s) value is what the provider falls back to the public // instance for, so it counts as official and stays rate limited. private static bool IsOfficialServer(string? baseUrl) { if (!Uri.TryCreate(baseUrl?.Trim(), UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) { return true; } return uri.Host.Equals(OfficialHost, StringComparison.OrdinalIgnoreCase) || uri.Host.EndsWith(OfficialHostSuffix, StringComparison.OrdinalIgnoreCase); } /// /// Stamps every outbound LRCLIB request with a fixed, identifiable User-Agent so the LRCLIB /// operators can differentiate this plugin from jellyfin/jellyfin-plugin-lrclib (see issue #49). /// private static void ApplyUserAgent(HttpRequestMessage request) { request.Headers.UserAgent.Clear(); request.Headers.UserAgent.Add(_uaProduct); request.Headers.UserAgent.Add(_uaComment); } private static string ResolveVersion() { var version = LyricsPlugin.Instance?.Version ?? typeof(LrclibRateLimiter).Assembly.GetName().Version; return version?.ToString() ?? "unknown"; } private static TimeSpan ResolveRetryAfter(RetryConditionHeaderValue? header) { TimeSpan? raw = null; if (header is not null) { if (header.Delta.HasValue) { raw = header.Delta.Value; } else if (header.Date.HasValue) { raw = header.Date.Value.UtcDateTime - DateTime.UtcNow; } } var resolved = raw ?? FallbackRetryAfter; if (resolved < MinimumGap) { resolved = MinimumGap; } else if (resolved > MaxRetryAfter) { resolved = MaxRetryAfter; } return resolved; } }