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; /// /// Minimal Reddit REST API client using the OAuth2 "client credentials" (app-only) flow. /// Every request goes through the . /// 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 _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; /// /// Initializes a new instance of the class. /// /// Logger. /// Rate limiter. public RedditClient(ILogger 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; } } /// /// Gets a value indicating whether the user has configured Reddit API credentials. /// public static bool IsConfigured { get { var config = Plugin.Instance?.Configuration; return config is not null && !string.IsNullOrWhiteSpace(config.RedditClientId) && !string.IsNullOrWhiteSpace(config.RedditClientSecret); } } /// /// Performs a rate-limited, authenticated GET against oauth.reddit.com and returns the JSON body. /// /// Path and query, e.g. /r/anime/search.json?q=... . /// Cancellation token. /// The parsed JSON document. public async Task 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"); } /// /// Verifies that the configured credentials can obtain an access token. /// /// Cancellation token. /// True if a token was obtained. public async Task TestCredentialsAsync(CancellationToken cancellationToken = default) { await GetAccessTokenAsync(cancellationToken, forceRefresh: true).ConfigureAwait(false); return !string.IsNullOrEmpty(_accessToken); } private async Task 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("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(); } } }