mirror of
https://github.com/Felitendo/jellyfin-plugin-lyrics.git
synced 2026-11-01 17:23:25 +01:00
261 lines
9.9 KiB
C#
261 lines
9.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Process-wide rate-limit gate for lrclib.net requests. Enforces a minimum gap between outbound
|
|
/// calls and honors <see cref="HttpStatusCode.TooManyRequests"/> and
|
|
/// <see cref="HttpStatusCode.ServiceUnavailable"/> together with the Retry-After header so a
|
|
/// transient spike doesn't get logged as a generic per-track error.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static long ThrottledResponseCount => Interlocked.Read(ref _throttledResponseCount);
|
|
|
|
/// <summary>
|
|
/// Gets the number of requests in a row that were still throttled after their retry. Any
|
|
/// other response resets it.
|
|
/// </summary>
|
|
public static int ConsecutiveThrottledResponses => Volatile.Read(ref _consecutiveThrottledResponses);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static bool IsRateLimitDisabled
|
|
{
|
|
get
|
|
{
|
|
var configuration = LyricsPlugin.Instance?.Configuration;
|
|
|
|
return configuration is not null
|
|
&& configuration.DisableRateLimit
|
|
&& !IsOfficialServer(configuration.LrclibBaseUrl);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="client">HTTP client to send the request on.</param>
|
|
/// <param name="requestFactory">Factory that builds a fresh <see cref="HttpRequestMessage"/> per attempt; needed because messages can't be re-sent.</param>
|
|
/// <param name="logger">Logger for backoff diagnostics.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The final response; caller is responsible for disposal and EnsureSuccessStatusCode.</returns>
|
|
public static async Task<HttpResponseMessage> SendAsync(
|
|
HttpClient client,
|
|
Func<HttpRequestMessage> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|