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;
}