init
This commit is contained in:
173
Jellyfin.Plugin.RedditComments/Services/RedditClient.cs
Normal file
173
Jellyfin.Plugin.RedditComments/Services/RedditClient.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.RedditComments.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Reddit REST API client using the OAuth2 "client credentials" (app-only) flow.
|
||||
/// Every request goes through the <see cref="RateLimiter"/>.
|
||||
/// </summary>
|
||||
public class RedditClient
|
||||
{
|
||||
private const string TokenUrl = "https://www.reddit.com/api/v1/access_token";
|
||||
private const string OAuthBaseUrl = "https://oauth.reddit.com";
|
||||
|
||||
private readonly ILogger<RedditClient> _logger;
|
||||
private readonly RateLimiter _rateLimiter;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SemaphoreSlim _tokenLock = new SemaphoreSlim(1, 1);
|
||||
|
||||
private string? _accessToken;
|
||||
private DateTime _tokenExpiresAt = DateTime.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedditClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="rateLimiter">Rate limiter.</param>
|
||||
public RedditClient(ILogger<RedditClient> logger, RateLimiter rateLimiter)
|
||||
{
|
||||
_logger = logger;
|
||||
_rateLimiter = rateLimiter;
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
private static string UserAgent
|
||||
{
|
||||
get
|
||||
{
|
||||
var ua = Plugin.Instance?.Configuration.RedditUserAgent;
|
||||
return string.IsNullOrWhiteSpace(ua)
|
||||
? "linux:jellyfin-plugin-reddit-comments:v1.0.0 (by /u/unknown)"
|
||||
: ua;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the user has configured Reddit API credentials.
|
||||
/// </summary>
|
||||
public static bool IsConfigured
|
||||
{
|
||||
get
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
return config is not null
|
||||
&& !string.IsNullOrWhiteSpace(config.RedditClientId)
|
||||
&& !string.IsNullOrWhiteSpace(config.RedditClientSecret);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs a rate-limited, authenticated GET against oauth.reddit.com and returns the JSON body.
|
||||
/// </summary>
|
||||
/// <param name="pathAndQuery">Path and query, e.g. /r/anime/search.json?q=... .</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The parsed JSON document.</returns>
|
||||
public async Task<JsonDocument> GetJsonAsync(string pathAndQuery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
await _rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, OAuthBaseUrl + pathAndQuery);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Headers.UserAgent.ParseAdd(UserAgent);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.Headers.Contains("x-ratelimit-remaining"))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Reddit rate limit: used={Used} remaining={Remaining} reset={Reset}s",
|
||||
response.Headers.TryGetValues("x-ratelimit-used", out var used) ? string.Join(',', used) : "?",
|
||||
response.Headers.TryGetValues("x-ratelimit-remaining", out var remaining) ? string.Join(',', remaining) : "?",
|
||||
response.Headers.TryGetValues("x-ratelimit-reset", out var reset) ? string.Join(',', reset) : "?");
|
||||
}
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && attempt == 0)
|
||||
{
|
||||
// Token rejected: force a refresh and retry once.
|
||||
_logger.LogWarning("Reddit returned 401, refreshing access token");
|
||||
token = await GetAccessTokenAsync(cancellationToken, forceRefresh: true).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
throw new HttpRequestException("Reddit request failed after token refresh");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the configured credentials can obtain an access token.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if a token was obtained.</returns>
|
||||
public async Task<bool> TestCredentialsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await GetAccessTokenAsync(cancellationToken, forceRefresh: true).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(_accessToken);
|
||||
}
|
||||
|
||||
private async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken, bool forceRefresh = false)
|
||||
{
|
||||
await _tokenLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (!forceRefresh
|
||||
&& !string.IsNullOrEmpty(_accessToken)
|
||||
&& DateTime.UtcNow < _tokenExpiresAt)
|
||||
{
|
||||
return _accessToken;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config is null || string.IsNullOrWhiteSpace(config.RedditClientId) || string.IsNullOrWhiteSpace(config.RedditClientSecret))
|
||||
{
|
||||
throw new InvalidOperationException("Reddit API credentials are not configured. Open Dashboard → Plugins → Reddit Comments and enter a client id and secret.");
|
||||
}
|
||||
|
||||
await _rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, TokenUrl);
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(config.RedditClientId.Trim() + ":" + config.RedditClientSecret.Trim()));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
request.Headers.UserAgent.ParseAdd(UserAgent);
|
||||
request.Content = new FormUrlEncodedContent(
|
||||
[
|
||||
new KeyValuePair<string, string>("grant_type", "client_credentials")
|
||||
]);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new HttpRequestException($"Reddit token request failed with {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_accessToken = doc.RootElement.GetProperty("access_token").GetString();
|
||||
var expiresIn = doc.RootElement.TryGetProperty("expires_in", out var exp) ? exp.GetInt32() : 3600;
|
||||
_tokenExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn - 60);
|
||||
|
||||
_logger.LogInformation("Obtained Reddit access token, valid for {Seconds}s", expiresIn);
|
||||
return _accessToken!;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tokenLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user