455 lines
18 KiB
C#
455 lines
18 KiB
C#
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;
|
|
}
|