This commit is contained in:
Gabrieal Jimmy
2026-07-16 13:14:58 -05:00
commit f321976d70
18 changed files with 2435 additions and 0 deletions

View File

@@ -0,0 +1,210 @@
using System;
using System.Globalization;
using System.IO;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RedditComments.Services;
/// <summary>
/// One cached lookup for a Jellyfin item.
/// </summary>
public class CachedThread
{
/// <summary>Gets or sets the Jellyfin item id.</summary>
public string ItemId { get; set; } = string.Empty;
/// <summary>Gets or sets the Reddit thread id (empty when nothing was found).</summary>
public string ThreadId { get; set; } = string.Empty;
/// <summary>Gets or sets the Reddit thread title.</summary>
public string ThreadTitle { get; set; } = string.Empty;
/// <summary>Gets or sets the subreddit the thread was found in.</summary>
public string Subreddit { get; set; } = string.Empty;
/// <summary>Gets or sets the thread permalink path.</summary>
public string Permalink { get; set; } = string.Empty;
/// <summary>Gets or sets the thread score.</summary>
public int ThreadScore { get; set; }
/// <summary>Gets or sets the thread's total comment count.</summary>
public int NumComments { get; set; }
/// <summary>Gets or sets when this entry was fetched (UTC).</summary>
public DateTime FetchedAt { get; set; }
/// <summary>Gets or sets the serialized comments JSON array.</summary>
public string CommentsJson { get; set; } = "[]";
}
/// <summary>
/// SQLite-backed cache of Reddit threads/comments for Jellyfin items.
/// Avoids hitting the Reddit API again for episodes that were already looked up.
/// </summary>
public class CommentCache
{
private readonly string _dbPath;
private readonly ILogger<CommentCache> _logger;
private readonly object _lock = new object();
private bool _initialized;
/// <summary>
/// Initializes a new instance of the <see cref="CommentCache"/> class.
/// </summary>
/// <param name="dbPath">Full path of the SQLite database file.</param>
/// <param name="logger">Logger.</param>
public CommentCache(string dbPath, ILogger<CommentCache> logger)
{
_dbPath = dbPath;
_logger = logger;
}
/// <summary>
/// Gets a cached entry if it exists and is still fresh.
/// </summary>
/// <param name="itemId">Jellyfin item id.</param>
/// <param name="foundTtl">Freshness window for successful lookups.</param>
/// <param name="notFoundTtl">Freshness window for negative lookups.</param>
/// <returns>The cached entry, or null if missing/stale.</returns>
public CachedThread? Get(string itemId, TimeSpan foundTtl, TimeSpan notFoundTtl)
{
lock (_lock)
{
EnsureInitialized();
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = "SELECT thread_id, thread_title, subreddit, permalink, thread_score, num_comments, fetched_at, comments_json FROM threads WHERE item_id = $itemId";
command.Parameters.AddWithValue("$itemId", itemId);
using var reader = command.ExecuteReader();
if (!reader.Read())
{
return null;
}
var entry = new CachedThread
{
ItemId = itemId,
ThreadId = reader.GetString(0),
ThreadTitle = reader.GetString(1),
Subreddit = reader.GetString(2),
Permalink = reader.GetString(3),
ThreadScore = reader.GetInt32(4),
NumComments = reader.GetInt32(5),
FetchedAt = DateTime.Parse(reader.GetString(6), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
CommentsJson = reader.GetString(7)
};
var found = entry.ThreadId.Length > 0;
var ttl = found ? foundTtl : notFoundTtl;
if (DateTime.UtcNow - entry.FetchedAt > ttl)
{
return null;
}
return entry;
}
}
/// <summary>
/// Stores or replaces the entry for an item. Stamps <see cref="CachedThread.FetchedAt"/> with the current time.
/// </summary>
/// <param name="entry">The entry to store.</param>
public void Set(CachedThread entry)
{
lock (_lock)
{
EnsureInitialized();
entry.FetchedAt = DateTime.UtcNow;
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO threads (item_id, thread_id, thread_title, subreddit, permalink, thread_score, num_comments, fetched_at, comments_json)
VALUES ($itemId, $threadId, $threadTitle, $subreddit, $permalink, $threadScore, $numComments, $fetchedAt, $commentsJson)
ON CONFLICT(item_id) DO UPDATE SET
thread_id = $threadId,
thread_title = $threadTitle,
subreddit = $subreddit,
permalink = $permalink,
thread_score = $threadScore,
num_comments = $numComments,
fetched_at = $fetchedAt,
comments_json = $commentsJson";
command.Parameters.AddWithValue("$itemId", entry.ItemId);
command.Parameters.AddWithValue("$threadId", entry.ThreadId);
command.Parameters.AddWithValue("$threadTitle", entry.ThreadTitle);
command.Parameters.AddWithValue("$subreddit", entry.Subreddit);
command.Parameters.AddWithValue("$permalink", entry.Permalink);
command.Parameters.AddWithValue("$threadScore", entry.ThreadScore);
command.Parameters.AddWithValue("$numComments", entry.NumComments);
command.Parameters.AddWithValue("$fetchedAt", entry.FetchedAt.ToString("O", CultureInfo.InvariantCulture));
command.Parameters.AddWithValue("$commentsJson", entry.CommentsJson);
command.ExecuteNonQuery();
}
}
/// <summary>
/// Deletes the cached entry for an item (used by manual refresh).
/// </summary>
/// <param name="itemId">Jellyfin item id.</param>
public void Invalidate(string itemId)
{
lock (_lock)
{
EnsureInitialized();
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = "DELETE FROM threads WHERE item_id = $itemId";
command.Parameters.AddWithValue("$itemId", itemId);
command.ExecuteNonQuery();
}
}
private void EnsureInitialized()
{
if (_initialized)
{
return;
}
var directory = Path.GetDirectoryName(_dbPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = @"
CREATE TABLE IF NOT EXISTS threads (
item_id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
thread_title TEXT NOT NULL,
subreddit TEXT NOT NULL,
permalink TEXT NOT NULL,
thread_score INTEGER NOT NULL,
num_comments INTEGER NOT NULL,
fetched_at TEXT NOT NULL,
comments_json TEXT NOT NULL
)";
command.ExecuteNonQuery();
_initialized = true;
_logger.LogInformation("Reddit comments cache initialized at {Path}", _dbPath);
}
private SqliteConnection OpenConnection()
{
var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadWriteCreate");
connection.Open();
using var pragma = connection.CreateCommand();
pragma.CommandText = "PRAGMA journal_mode=WAL";
pragma.ExecuteNonQuery();
return connection;
}
}

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.RedditComments.Services;
/// <summary>
/// Sliding-window rate limiter. Guarantees no more than N requests per rolling 60 seconds,
/// no matter how many callers are waiting.
/// </summary>
public class RateLimiter
{
private readonly object _lock = new object();
private readonly Queue<DateTime> _requestTimestamps = new Queue<DateTime>();
private readonly int? _maxPerMinuteOverride;
/// <summary>
/// Initializes a new instance of the <see cref="RateLimiter"/> class.
/// </summary>
/// <param name="maxPerMinuteOverride">Optional fixed limit (used by tests); otherwise the plugin configuration is used.</param>
public RateLimiter(int? maxPerMinuteOverride = null)
{
_maxPerMinuteOverride = maxPerMinuteOverride;
}
private int MaxPerMinute
{
get
{
if (_maxPerMinuteOverride.HasValue)
{
return _maxPerMinuteOverride.Value;
}
return ReadConfiguredLimit();
}
}
private static int ReadConfiguredLimit()
{
var configured = Plugin.Instance?.Configuration.MaxRequestsPerMinute ?? 60;
// Reddit's own hard limit for OAuth clients is 100 req/min; never exceed it.
return Math.Clamp(configured, 1, 100);
}
/// <summary>
/// Waits until a request slot is available, then reserves it.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when the caller may perform one request.</returns>
public async Task WaitAsync(CancellationToken cancellationToken = default)
{
while (true)
{
TimeSpan delay;
lock (_lock)
{
var now = DateTime.UtcNow;
while (_requestTimestamps.Count > 0 && now - _requestTimestamps.Peek() >= TimeSpan.FromMinutes(1))
{
_requestTimestamps.Dequeue();
}
if (_requestTimestamps.Count < MaxPerMinute)
{
_requestTimestamps.Enqueue(now);
return;
}
// Window is full: wait until the oldest request falls out of the window.
delay = TimeSpan.FromMinutes(1) - (now - _requestTimestamps.Peek()) + TimeSpan.FromMilliseconds(100);
}
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
}
}

View 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();
}
}
}

View File

@@ -0,0 +1,454 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.RedditComments.Models;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RedditComments.Services;
/// <summary>
/// Finds the Reddit discussion thread for a Jellyfin item and fetches its comments.
/// Results are cached in SQLite so the Reddit API is only hit on the first request per item.
/// </summary>
public class RedditCommentsService
{
private static readonly HashSet<string> Stopwords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"the", "a", "an", "of", "and", "or", "to", "in", "on", "at", "is", "it", "no", "wa", "ga", "ni", "wo", "o", "e", "de", "season", "part", "cour"
};
private readonly ILogger<RedditCommentsService> _logger;
private readonly RedditClient _redditClient;
private readonly CommentCache _cache;
/// <summary>
/// Initializes a new instance of the <see cref="RedditCommentsService"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="redditClient">Reddit API client.</param>
/// <param name="cache">Comment cache.</param>
public RedditCommentsService(ILogger<RedditCommentsService> logger, RedditClient redditClient, CommentCache cache)
{
_logger = logger;
_redditClient = redditClient;
_cache = cache;
}
/// <summary>
/// Gets the Reddit comments for a Jellyfin item, using the cache when possible.
/// </summary>
/// <param name="item">The Jellyfin item (episode or movie).</param>
/// <param name="forceRefresh">Bypass the cache and fetch fresh data.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The comments response.</returns>
public async Task<CommentsResponse> GetForItemAsync(BaseItem item, bool forceRefresh, CancellationToken cancellationToken)
{
var config = Plugin.Instance!.Configuration;
var itemId = item.Id.ToString("N", CultureInfo.InvariantCulture);
var mediaLabel = BuildMediaLabel(item);
if (item is not Episode && item is not Movie)
{
return new CommentsResponse
{
Found = false,
ItemId = itemId,
MediaLabel = mediaLabel,
Message = "Reddit comments are only supported for episodes and movies."
};
}
if (!forceRefresh)
{
var cached = _cache.Get(itemId, TimeSpan.FromDays(Math.Max(1, config.CacheDays)), TimeSpan.FromHours(Math.Max(1, config.NotFoundCacheHours)));
if (cached is not null)
{
return FromCache(cached, itemId, mediaLabel);
}
}
else
{
_cache.Invalidate(itemId);
}
if (!RedditClient.IsConfigured)
{
return new CommentsResponse
{
Found = false,
ItemId = itemId,
MediaLabel = mediaLabel,
Message = "Reddit API credentials are not configured. Ask your server admin to open Dashboard → Plugins → Reddit Comments."
};
}
var subreddits = (config.Subreddits ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(s => s.Length > 0)
.ToList();
if (subreddits.Count == 0)
{
subreddits.Add("anime");
}
var queries = BuildQueries(item);
var candidates = new List<(ThreadCandidate Candidate, int SubredditIndex)>();
foreach (var (subreddit, subIndex) in subreddits.Select((s, i) => (s, i)))
{
foreach (var query in queries)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var path = string.Format(
CultureInfo.InvariantCulture,
"/r/{0}/search.json?q={1}&restrict_sr=1&sort=relevance&t=all&type=link&limit=10&raw_json=1",
Uri.EscapeDataString(subreddit),
Uri.EscapeDataString(query));
using var doc = await _redditClient.GetJsonAsync(path, cancellationToken).ConfigureAwait(false);
foreach (var candidate in ParseSearchCandidates(doc))
{
if (!candidates.Any(c => c.Candidate.Id == candidate.Id))
{
candidates.Add((candidate, subIndex));
}
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Reddit search failed for query \"{Query}\" in r/{Subreddit}", query, subreddit);
}
}
}
var best = PickBestCandidate(item, candidates);
if (best is null)
{
_logger.LogInformation("No Reddit thread found for {Label} (searched {Count} candidates)", mediaLabel, candidates.Count);
var notFound = new CachedThread { ItemId = itemId };
_cache.Set(notFound);
return FromCache(notFound, itemId, mediaLabel, cached: false);
}
_logger.LogInformation("Found Reddit thread for {Label}: {Title} (r/{Subreddit}, {Comments} comments)", mediaLabel, best.Title, best.Subreddit, best.NumComments);
var commentsPath = string.Format(
CultureInfo.InvariantCulture,
"/comments/{0}.json?raw_json=1&limit={1}&depth={2}&sort=top",
best.Id,
Math.Clamp(config.MaxComments, 1, 500),
Math.Clamp(config.CommentDepth, 1, 10));
using var commentsDoc = await _redditClient.GetJsonAsync(commentsPath, cancellationToken).ConfigureAwait(false);
var comments = ParseCommentTree(commentsDoc, config.MinScore);
var entry = new CachedThread
{
ItemId = itemId,
ThreadId = best.Id,
ThreadTitle = best.Title,
Subreddit = best.Subreddit,
Permalink = best.Permalink,
ThreadScore = best.Score,
NumComments = best.NumComments,
CommentsJson = JsonSerializer.Serialize(comments)
};
_cache.Set(entry);
return FromCache(entry, itemId, mediaLabel, cached: false);
}
/// <summary>
/// Parses the children of a subreddit search listing.
/// </summary>
/// <param name="searchDoc">The parsed search response.</param>
/// <returns>The list of thread candidates.</returns>
public static List<ThreadCandidate> ParseSearchCandidates(JsonDocument searchDoc)
{
var result = new List<ThreadCandidate>();
if (!searchDoc.RootElement.TryGetProperty("data", out var data) || !data.TryGetProperty("children", out var children))
{
return result;
}
foreach (var child in children.EnumerateArray())
{
if (!child.TryGetProperty("data", out var post))
{
continue;
}
result.Add(new ThreadCandidate
{
Id = GetString(post, "id"),
Title = GetString(post, "title"),
Subreddit = GetString(post, "subreddit"),
Permalink = GetString(post, "permalink"),
Score = GetInt(post, "score"),
NumComments = GetInt(post, "num_comments"),
LinkFlairText = GetString(post, "link_flair_text")
});
}
return result;
}
/// <summary>
/// Parses a /comments/{id} response (array of [post listing, comments listing]) into a comment tree.
/// </summary>
/// <param name="commentsDoc">The parsed comments response.</param>
/// <param name="minScore">Minimum score for a comment to be included.</param>
/// <returns>The top-level comments.</returns>
public static List<CommentDto> ParseCommentTree(JsonDocument commentsDoc, int minScore)
{
var result = new List<CommentDto>();
if (commentsDoc.RootElement.ValueKind != JsonValueKind.Array || commentsDoc.RootElement.GetArrayLength() < 2)
{
return result;
}
var listing = commentsDoc.RootElement[1];
if (!listing.TryGetProperty("data", out var data) || !data.TryGetProperty("children", out var children))
{
return result;
}
foreach (var child in children.EnumerateArray())
{
var comment = ParseComment(child, minScore);
if (comment is not null)
{
result.Add(comment);
}
}
return result;
}
private static CommentDto? ParseComment(JsonElement child, int minScore)
{
if (!child.TryGetProperty("kind", out var kind) || kind.GetString() != "t1")
{
return null; // skip "more" and anything else
}
var data = child.GetProperty("data");
var body = GetString(data, "body");
if (string.IsNullOrEmpty(body) || body == "[deleted]" || body == "[removed]")
{
return null;
}
var score = GetInt(data, "score");
if (score < minScore)
{
return null;
}
var comment = new CommentDto
{
Author = GetString(data, "author"),
Body = body,
Score = score,
CreatedUtc = GetLong(data, "created_utc")
};
if (data.TryGetProperty("replies", out var replies) && replies.ValueKind == JsonValueKind.Object
&& replies.TryGetProperty("data", out var repliesData) && repliesData.TryGetProperty("children", out var replyChildren))
{
foreach (var replyChild in replyChildren.EnumerateArray())
{
var reply = ParseComment(replyChild, minScore);
if (reply is not null)
{
comment.Replies.Add(reply);
}
}
}
return comment;
}
private static string BuildMediaLabel(BaseItem item)
{
if (item is Episode episode)
{
var series = GetSeriesName(episode) ?? episode.Name;
var ep = episode.IndexNumber ?? 0;
var season = episode.ParentIndexNumber ?? 1;
return season > 1
? string.Format(CultureInfo.InvariantCulture, "{0} — Season {1} Episode {2}", series, season, ep)
: string.Format(CultureInfo.InvariantCulture, "{0} — Episode {1}", series, ep);
}
if (item is Movie movie)
{
return movie.ProductionYear.HasValue
? string.Format(CultureInfo.InvariantCulture, "{0} ({1})", movie.Name, movie.ProductionYear.Value)
: movie.Name;
}
return item.Name;
}
private static string? GetSeriesName(Episode episode)
{
if (!string.IsNullOrWhiteSpace(episode.SeriesName))
{
return episode.SeriesName;
}
return episode.FindParent<Series>()?.Name;
}
private static List<string> BuildQueries(BaseItem item)
{
var queries = new List<string>();
if (item is Episode episode)
{
var series = GetSeriesName(episode) ?? episode.Name;
var ep = episode.IndexNumber ?? 0;
var season = episode.ParentIndexNumber ?? 1;
queries.Add(string.Format(CultureInfo.InvariantCulture, "\"{0}\" episode {1}", series, ep));
if (season > 1)
{
queries.Add(string.Format(CultureInfo.InvariantCulture, "\"{0}\" season {1} episode {2}", series, season, ep));
}
var original = episode.FindParent<Series>()?.OriginalTitle;
if (!string.IsNullOrWhiteSpace(original) && !original.Equals(series, StringComparison.OrdinalIgnoreCase))
{
queries.Add(string.Format(CultureInfo.InvariantCulture, "\"{0}\" episode {1}", original, ep));
}
}
else if (item is Movie movie)
{
queries.Add(string.Format(CultureInfo.InvariantCulture, "\"{0}\" discussion", movie.Name));
if (!string.IsNullOrWhiteSpace(movie.OriginalTitle) && !movie.OriginalTitle.Equals(movie.Name, StringComparison.OrdinalIgnoreCase))
{
queries.Add(string.Format(CultureInfo.InvariantCulture, "\"{0}\" discussion", movie.OriginalTitle));
}
}
return queries;
}
private static ThreadCandidate? PickBestCandidate(BaseItem item, List<(ThreadCandidate Candidate, int SubredditIndex)> candidates)
{
if (candidates.Count == 0)
{
return null;
}
if (item is Episode episode)
{
var series = GetSeriesName(episode) ?? episode.Name;
var ep = episode.IndexNumber ?? 0;
var titleTokens = Tokenize(series);
var episodeRegex = new Regex(@"\b(?:episode|ep\.?)\s*#?\s*0?" + Regex.Escape(ep.ToString(CultureInfo.InvariantCulture)) + @"\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
var scored = candidates
.Select(c =>
{
var episodeMatch = episodeRegex.IsMatch(c.Candidate.Title);
var overlap = Tokenize(c.Candidate.Title).Count(titleTokens.Contains);
var score = (episodeMatch ? 100 : 0)
+ (overlap * 10)
+ (c.Candidate.LinkFlairText.Contains("episode", StringComparison.OrdinalIgnoreCase) ? 5 : 0)
+ Math.Min(c.Candidate.NumComments / 100, 9)
- c.SubredditIndex;
return (c.Candidate, EpisodeMatch: episodeMatch, Overlap: overlap, Score: score);
})
.OrderByDescending(c => c.Score)
.ToList();
// Require the episode number to match, plus at least one title token in common.
var best = scored.FirstOrDefault(c => c.EpisodeMatch && c.Overlap >= 1);
if (best.Candidate is not null)
{
return best.Candidate;
}
// Fallback: if exactly one candidate matches the episode number, trust the search relevance.
var episodeMatches = scored.Where(c => c.EpisodeMatch).ToList();
if (episodeMatches.Count == 1)
{
return episodeMatches[0].Candidate;
}
return null;
}
if (item is Movie movie)
{
var titleTokens = Tokenize(movie.Name);
var scored = candidates
.Select(c =>
{
var overlap = Tokenize(c.Candidate.Title).Count(titleTokens.Contains);
var discussion = c.Candidate.Title.Contains("discussion", StringComparison.OrdinalIgnoreCase);
var score = (overlap * 10)
+ (discussion ? 20 : 0)
+ (c.Candidate.Title.Contains("movie", StringComparison.OrdinalIgnoreCase) ? 5 : 0)
+ Math.Min(c.Candidate.NumComments / 100, 9)
- c.SubredditIndex;
return (c.Candidate, Overlap: overlap, Discussion: discussion, Score: score);
})
.OrderByDescending(c => c.Score)
.ToList();
var best = scored.FirstOrDefault(c => c.Discussion && c.Overlap >= 1);
return best.Candidate;
}
return null;
}
private static HashSet<string> Tokenize(string text)
{
var tokens = Regex.Split(text.ToLowerInvariant(), @"[^a-z0-9]+")
.Where(t => t.Length >= 2 && !Stopwords.Contains(t));
return new HashSet<string>(tokens, StringComparer.OrdinalIgnoreCase);
}
private CommentsResponse FromCache(CachedThread entry, string itemId, string mediaLabel, bool cached = true)
{
var found = entry.ThreadId.Length > 0;
return new CommentsResponse
{
Found = found,
ItemId = itemId,
MediaLabel = mediaLabel,
ThreadId = entry.ThreadId,
ThreadTitle = entry.ThreadTitle,
Subreddit = entry.Subreddit,
Permalink = entry.Permalink,
ThreadScore = entry.ThreadScore,
NumComments = entry.NumComments,
FetchedAt = entry.FetchedAt == default ? DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) : entry.FetchedAt.ToString("O", CultureInfo.InvariantCulture),
Cached = cached,
Message = found ? string.Empty : "No Reddit discussion thread was found for this title.",
Comments = found
? JsonSerializer.Deserialize<List<CommentDto>>(entry.CommentsJson) ?? new List<CommentDto>()
: new List<CommentDto>()
};
}
private static string GetString(JsonElement element, string property)
=> element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? string.Empty : string.Empty;
private static int GetInt(JsonElement element, string property)
=> element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var result) ? result : 0;
private static long GetLong(JsonElement element, string property)
=> element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var result) ? result : 0;
}

View File

@@ -0,0 +1,106 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RedditComments.Services;
/// <summary>
/// Injects the plugin's script tag into the Jellyfin web client's index.html at startup,
/// so the player gets the comments button. Re-applies automatically after web client updates.
/// </summary>
public class WebScriptInjector : IHostedService
{
private const string ScriptMarker = "RedditComments/Static/reddit-comments.js";
private readonly ILogger<WebScriptInjector> _logger;
private readonly IApplicationPaths _applicationPaths;
private readonly IServerConfigurationManager _configurationManager;
/// <summary>
/// Initializes a new instance of the <see cref="WebScriptInjector"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="applicationPaths">Application paths.</param>
/// <param name="configurationManager">Server configuration manager.</param>
public WebScriptInjector(
ILogger<WebScriptInjector> logger,
IApplicationPaths applicationPaths,
IServerConfigurationManager configurationManager)
{
_logger = logger;
_applicationPaths = applicationPaths;
_configurationManager = configurationManager;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
try
{
InjectScriptTag();
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Could not inject the Reddit Comments script into the web client. The API will still work; "
+ "to enable the player button, add this tag to index.html of jellyfin-web manually: {Tag}",
BuildScriptTag());
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private string BuildScriptTag()
{
var baseUrl = string.Empty;
try
{
if (_configurationManager.GetConfiguration("network") is NetworkConfiguration networkConfiguration)
{
baseUrl = (networkConfiguration.BaseUrl ?? string.Empty).TrimEnd('/');
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not read the configured base URL, assuming none");
}
return string.Format(System.Globalization.CultureInfo.InvariantCulture, "<script src=\"{0}/{1}\" defer></script>", baseUrl, ScriptMarker);
}
private void InjectScriptTag()
{
var indexPath = Path.Combine(_applicationPaths.WebPath, "index.html");
if (!File.Exists(indexPath))
{
_logger.LogWarning("jellyfin-web index.html not found at {Path}, skipping script injection", indexPath);
return;
}
var contents = File.ReadAllText(indexPath);
if (contents.Contains(ScriptMarker, StringComparison.Ordinal))
{
_logger.LogDebug("Reddit Comments script tag already present in index.html");
return;
}
var tag = BuildScriptTag();
var bodyIndex = contents.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase);
contents = bodyIndex >= 0
? contents.Insert(bodyIndex, tag + Environment.NewLine)
: contents + Environment.NewLine + tag + Environment.NewLine;
File.WriteAllText(indexPath, contents);
_logger.LogInformation("Injected Reddit Comments script tag into {Path}", indexPath);
}
}