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; /// /// 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. /// public class RedditCommentsService { private static readonly HashSet Stopwords = new HashSet(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 _logger; private readonly RedditClient _redditClient; private readonly CommentCache _cache; /// /// Initializes a new instance of the class. /// /// Logger. /// Reddit API client. /// Comment cache. public RedditCommentsService(ILogger logger, RedditClient redditClient, CommentCache cache) { _logger = logger; _redditClient = redditClient; _cache = cache; } /// /// Gets the Reddit comments for a Jellyfin item, using the cache when possible. /// /// The Jellyfin item (episode or movie). /// Bypass the cache and fetch fresh data. /// Cancellation token. /// The comments response. public async Task 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); } /// /// Parses the children of a subreddit search listing. /// /// The parsed search response. /// The list of thread candidates. public static List ParseSearchCandidates(JsonDocument searchDoc) { var result = new List(); 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; } /// /// Parses a /comments/{id} response (array of [post listing, comments listing]) into a comment tree. /// /// The parsed comments response. /// Minimum score for a comment to be included. /// The top-level comments. public static List ParseCommentTree(JsonDocument commentsDoc, int minScore) { var result = new List(); 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()?.Name; } private static List BuildQueries(BaseItem item) { var queries = new List(); 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()?.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 Tokenize(string text) { var tokens = Regex.Split(text.ToLowerInvariant(), @"[^a-z0-9]+") .Where(t => t.Length >= 2 && !Stopwords.Contains(t)); return new HashSet(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>(entry.CommentsJson) ?? new List() : new List() }; } 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; }