commit f321976d70dbe685950e12c814ec9e36a20f389e Author: Gabrieal Jimmy Date: Thu Jul 16 13:14:58 2026 -0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10a4505 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# .NET build output +bin/ +obj/ +out/ + +# Build artifacts +dist/ +publish/ + +# Rider / Visual Studio / VS Code +.idea/ +.vs/ +.vscode/ +*.user +*.suo + +# OS noise +.DS_Store +Thumbs.db diff --git a/Jellyfin.Plugin.RedditComments/Api/RedditCommentsController.cs b/Jellyfin.Plugin.RedditComments/Api/RedditCommentsController.cs new file mode 100644 index 0000000..0229399 --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/Api/RedditCommentsController.cs @@ -0,0 +1,170 @@ +using System; +using System.Globalization; +using System.IO; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.RedditComments.Models; +using Jellyfin.Plugin.RedditComments.Services; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.RedditComments.Api; + +/// +/// API endpoints for the Reddit Comments plugin. +/// +[ApiController] +[Route("RedditComments")] +public class RedditCommentsController : ControllerBase +{ + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly RedditCommentsService _commentsService; + private readonly RedditClient _redditClient; + + /// + /// Initializes a new instance of the class. + /// + /// Logger. + /// Library manager. + /// Comments service. + /// Reddit client. + public RedditCommentsController( + ILogger logger, + ILibraryManager libraryManager, + RedditCommentsService commentsService, + RedditClient redditClient) + { + _logger = logger; + _libraryManager = libraryManager; + _commentsService = commentsService; + _redditClient = redditClient; + } + + /// + /// Gets the Reddit discussion comments for a Jellyfin item. Served from the local cache + /// unless this is the first lookup or the cache expired. + /// + /// The Jellyfin item id. + /// Cancellation token. + /// The comments response. + [HttpGet("Item/{itemId}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetComments([FromRoute] Guid itemId, CancellationToken cancellationToken) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } + + try + { + return await _commentsService.GetForItemAsync(item, forceRefresh: false, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get Reddit comments for item {ItemId}", itemId); + return new CommentsResponse + { + Found = false, + ItemId = itemId.ToString("N", CultureInfo.InvariantCulture), + Message = "Failed to fetch Reddit comments: " + ex.Message + }; + } + } + + /// + /// Forces a fresh lookup for an item, bypassing and replacing the cache. + /// + /// The Jellyfin item id. + /// Cancellation token. + /// The comments response. + [HttpPost("Item/{itemId}/Refresh")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> RefreshComments([FromRoute] Guid itemId, CancellationToken cancellationToken) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } + + try + { + return await _commentsService.GetForItemAsync(item, forceRefresh: true, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh Reddit comments for item {ItemId}", itemId); + return new CommentsResponse + { + Found = false, + ItemId = itemId.ToString("N", CultureInfo.InvariantCulture), + Message = "Failed to fetch Reddit comments: " + ex.Message + }; + } + } + + /// + /// Tests the configured Reddit API credentials. Admin only. + /// + /// Cancellation token. + /// Whether the credentials work. + [HttpGet("Test")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> TestCredentials(CancellationToken cancellationToken) + { + if (!RedditClient.IsConfigured) + { + return new { ok = false, message = "Client id and secret are not configured." }; + } + + try + { + await _redditClient.TestCredentialsAsync(cancellationToken).ConfigureAwait(false); + return new { ok = true, message = "Successfully authenticated with Reddit." }; + } + catch (Exception ex) + { + return new { ok = false, message = ex.Message }; + } + } + + /// + /// Serves the web client script that adds the comments button/sidebar to the player. + /// + /// The JavaScript file. + [HttpGet("Static/reddit-comments.js")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetScript() + { + var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("Jellyfin.Plugin.RedditComments.Web.reddit-comments.js"); + if (stream is null) + { + return NotFound(); + } + + // Cache for an hour so restarts/plugin updates pick up changes reasonably fast. + Response.Headers.CacheControl = "public, max-age=3600"; + return File(stream, "text/javascript"); + } +} diff --git a/Jellyfin.Plugin.RedditComments/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.RedditComments/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..918d6b6 --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/Configuration/PluginConfiguration.cs @@ -0,0 +1,76 @@ +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.RedditComments.Configuration; + +/// +/// Plugin configuration. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + public PluginConfiguration() + { + RedditClientId = string.Empty; + RedditClientSecret = string.Empty; + RedditUserAgent = "linux:jellyfin-plugin-reddit-comments:v1.0.0 (by /u/unknown)"; + Subreddits = "anime"; + CacheDays = 30; + NotFoundCacheHours = 24; + MaxRequestsPerMinute = 60; + MaxComments = 150; + CommentDepth = 8; + MinScore = 1; + } + + /// + /// Gets or sets the Reddit app client id (create a "script" app at https://www.reddit.com/prefs/apps). + /// + public string RedditClientId { get; set; } + + /// + /// Gets or sets the Reddit app client secret. + /// + public string RedditClientSecret { get; set; } + + /// + /// Gets or sets the User-Agent sent to Reddit. Reddit requires a unique, descriptive value. + /// + public string RedditUserAgent { get; set; } + + /// + /// Gets or sets the comma-separated list of subreddits to search, in order (without the r/ prefix). + /// + public string Subreddits { get; set; } + + /// + /// Gets or sets how many days a found thread is cached before it is fetched again. + /// + public int CacheDays { get; set; } + + /// + /// Gets or sets how many hours a "no thread found" result is cached. + /// + public int NotFoundCacheHours { get; set; } + + /// + /// Gets or sets the maximum number of Reddit API requests per minute (Reddit's own limit is 100). + /// + public int MaxRequestsPerMinute { get; set; } + + /// + /// Gets or sets the maximum number of comments requested from Reddit per thread. + /// + public int MaxComments { get; set; } + + /// + /// Gets or sets the maximum reply depth requested from Reddit. + /// + public int CommentDepth { get; set; } + + /// + /// Gets or sets the minimum score a comment must have to be shown. + /// + public int MinScore { get; set; } +} diff --git a/Jellyfin.Plugin.RedditComments/Configuration/configPage.html b/Jellyfin.Plugin.RedditComments/Configuration/configPage.html new file mode 100644 index 0000000..fe3c87f --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/Configuration/configPage.html @@ -0,0 +1,168 @@ +
+
+
+ + +

Reddit Comments

+ +
+ Reddit API setup (one time): +
    +
  1. Go to reddit.com/prefs/apps and click create app.
  2. +
  3. Choose type script, any name, and http://localhost as the redirect uri.
  4. +
  5. Copy the client id (under the app name) and the secret into the fields below.
  6. +
+
+ +
+
Reddit API
+ +
+ + +
+
+ + +
+
+ + +
Reddit requires a unique, descriptive user agent, e.g. linux:jellyfin-reddit-comments:v1.0 (by /u/yourname).
+
+ + + +
Search
+ +
+ + +
Comma-separated, without the r/ prefix. Searched in order. Default: anime
+
+ +
Caching & rate limits
+ +
+ + +
+
+ + +
+
+ + +
Hard cap enforced by the plugin. Reddit's own limit is 100/min; 60 is a safe default.
+
+ +
Comments
+ +
+ + +
+
+ + +
+
+ + +
Comments below this score are hidden. 1 filters out downvoted comments.
+
+ + +
+
+
+ + +
diff --git a/Jellyfin.Plugin.RedditComments/Jellyfin.Plugin.RedditComments.csproj b/Jellyfin.Plugin.RedditComments/Jellyfin.Plugin.RedditComments.csproj new file mode 100644 index 0000000..1055326 --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/Jellyfin.Plugin.RedditComments.csproj @@ -0,0 +1,31 @@ + + + + net9.0 + Jellyfin.Plugin.RedditComments + Jellyfin.Plugin.RedditComments + 1.0.0.0 + 1.0.0.0 + true + enable + disable + + + + + runtime + + + runtime + + + + + + + + + + + + diff --git a/Jellyfin.Plugin.RedditComments/Models/Dtos.cs b/Jellyfin.Plugin.RedditComments/Models/Dtos.cs new file mode 100644 index 0000000..8833810 --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/Models/Dtos.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.RedditComments.Models; + +/// +/// A single Reddit comment with nested replies. +/// +public class CommentDto +{ + /// Gets or sets the comment author. + public string Author { get; set; } = string.Empty; + + /// Gets or sets the comment body (plain text / markdown source). + public string Body { get; set; } = string.Empty; + + /// Gets or sets the comment score. + public int Score { get; set; } + + /// Gets or sets the creation time (unix seconds, UTC). + public long CreatedUtc { get; set; } + + /// Gets or sets the nested replies. + public List Replies { get; set; } = new List(); +} + +/// +/// Response returned by the plugin API for a Jellyfin item. +/// +public class CommentsResponse +{ + /// Gets or sets a value indicating whether a Reddit thread was found. + public bool Found { get; set; } + + /// Gets or sets the Jellyfin item id. + public string ItemId { get; set; } = string.Empty; + + /// Gets or sets a human-readable label for the media (e.g. "Show — Episode 5"). + public string MediaLabel { get; set; } = string.Empty; + + /// Gets or sets the Reddit thread id. + public string ThreadId { get; set; } = string.Empty; + + /// Gets or sets the Reddit thread title. + public string ThreadTitle { get; set; } = string.Empty; + + /// Gets or sets the subreddit. + public string Subreddit { get; set; } = string.Empty; + + /// Gets or sets the thread permalink path. + public string Permalink { get; set; } = string.Empty; + + /// Gets or sets the thread score. + public int ThreadScore { get; set; } + + /// Gets or sets the thread's total comment count. + public int NumComments { get; set; } + + /// Gets or sets when the data was fetched from Reddit (ISO 8601, UTC). + public string FetchedAt { get; set; } = string.Empty; + + /// Gets or sets a value indicating whether the response came from the local cache. + public bool Cached { get; set; } + + /// Gets or sets an informational message (e.g. why nothing was found). + public string Message { get; set; } = string.Empty; + + /// Gets or sets the top-level comments. + public List Comments { get; set; } = new List(); +} + +/// +/// A Reddit thread candidate found via search. +/// +public class ThreadCandidate +{ + /// Gets or sets the thread id. + public string Id { get; set; } = string.Empty; + + /// Gets or sets the thread title. + public string Title { get; set; } = string.Empty; + + /// Gets or sets the subreddit. + public string Subreddit { get; set; } = string.Empty; + + /// Gets or sets the permalink path. + public string Permalink { get; set; } = string.Empty; + + /// Gets or sets the thread score. + public int Score { get; set; } + + /// Gets or sets the thread's comment count. + public int NumComments { get; set; } + + /// Gets or sets the link flair text, if any. + public string LinkFlairText { get; set; } = string.Empty; +} diff --git a/Jellyfin.Plugin.RedditComments/Plugin.cs b/Jellyfin.Plugin.RedditComments/Plugin.cs new file mode 100644 index 0000000..2453bd6 --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/Plugin.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Jellyfin.Plugin.RedditComments.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Plugin.RedditComments; + +/// +/// The main plugin. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// + /// The plugin GUID. Must match the id used in the config page and repository manifest. + /// + public const string PluginGuid = "7b2f1c4e-9a3d-4f6b-8c5e-2d1a0f9e7b6c"; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + public override string Name => "Reddit Comments"; + + /// + public override string Description => "Shows Reddit discussion threads (e.g. r/anime episode threads) in the video player."; + + /// + public override Guid Id => Guid.Parse(PluginGuid); + + /// + /// Gets the current plugin instance. + /// + public static Plugin? Instance { get; private set; } + + /// + public IEnumerable GetPages() + { + return + [ + new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace) + } + ]; + } +} diff --git a/Jellyfin.Plugin.RedditComments/PluginServiceRegistrator.cs b/Jellyfin.Plugin.RedditComments/PluginServiceRegistrator.cs new file mode 100644 index 0000000..ac4d25b --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/PluginServiceRegistrator.cs @@ -0,0 +1,34 @@ +using System.IO; +using Jellyfin.Plugin.RedditComments.Services; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.RedditComments; + +/// +/// Registers the plugin's services in the Jellyfin DI container. +/// +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + var dbPath = Path.Combine( + applicationHost.Resolve().PluginConfigurationsPath, + "RedditComments", + "reddit-comments.db"); + + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(sp => new RedditClient(sp.GetRequiredService>(), sp.GetRequiredService())); + serviceCollection.AddSingleton(sp => new CommentCache(dbPath, sp.GetRequiredService>())); + serviceCollection.AddSingleton(sp => new RedditCommentsService( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + serviceCollection.AddHostedService(); + } +} diff --git a/Jellyfin.Plugin.RedditComments/Services/CommentCache.cs b/Jellyfin.Plugin.RedditComments/Services/CommentCache.cs new file mode 100644 index 0000000..d89cbd5 --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/Services/CommentCache.cs @@ -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; + +/// +/// One cached lookup for a Jellyfin item. +/// +public class CachedThread +{ + /// Gets or sets the Jellyfin item id. + public string ItemId { get; set; } = string.Empty; + + /// Gets or sets the Reddit thread id (empty when nothing was found). + public string ThreadId { get; set; } = string.Empty; + + /// Gets or sets the Reddit thread title. + public string ThreadTitle { get; set; } = string.Empty; + + /// Gets or sets the subreddit the thread was found in. + public string Subreddit { get; set; } = string.Empty; + + /// Gets or sets the thread permalink path. + public string Permalink { get; set; } = string.Empty; + + /// Gets or sets the thread score. + public int ThreadScore { get; set; } + + /// Gets or sets the thread's total comment count. + public int NumComments { get; set; } + + /// Gets or sets when this entry was fetched (UTC). + public DateTime FetchedAt { get; set; } + + /// Gets or sets the serialized comments JSON array. + public string CommentsJson { get; set; } = "[]"; +} + +/// +/// SQLite-backed cache of Reddit threads/comments for Jellyfin items. +/// Avoids hitting the Reddit API again for episodes that were already looked up. +/// +public class CommentCache +{ + private readonly string _dbPath; + private readonly ILogger _logger; + private readonly object _lock = new object(); + private bool _initialized; + + /// + /// Initializes a new instance of the class. + /// + /// Full path of the SQLite database file. + /// Logger. + public CommentCache(string dbPath, ILogger logger) + { + _dbPath = dbPath; + _logger = logger; + } + + /// + /// Gets a cached entry if it exists and is still fresh. + /// + /// Jellyfin item id. + /// Freshness window for successful lookups. + /// Freshness window for negative lookups. + /// The cached entry, or null if missing/stale. + 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; + } + } + + /// + /// Stores or replaces the entry for an item. Stamps with the current time. + /// + /// The entry to store. + 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(); + } + } + + /// + /// Deletes the cached entry for an item (used by manual refresh). + /// + /// Jellyfin item id. + 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; + } +} diff --git a/Jellyfin.Plugin.RedditComments/Services/RateLimiter.cs b/Jellyfin.Plugin.RedditComments/Services/RateLimiter.cs new file mode 100644 index 0000000..023caa6 --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/Services/RateLimiter.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.RedditComments.Services; + +/// +/// Sliding-window rate limiter. Guarantees no more than N requests per rolling 60 seconds, +/// no matter how many callers are waiting. +/// +public class RateLimiter +{ + private readonly object _lock = new object(); + private readonly Queue _requestTimestamps = new Queue(); + private readonly int? _maxPerMinuteOverride; + + /// + /// Initializes a new instance of the class. + /// + /// Optional fixed limit (used by tests); otherwise the plugin configuration is used. + 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); + } + + /// + /// Waits until a request slot is available, then reserves it. + /// + /// Cancellation token. + /// A task that completes when the caller may perform one request. + 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); + } + } +} diff --git a/Jellyfin.Plugin.RedditComments/Services/RedditClient.cs b/Jellyfin.Plugin.RedditComments/Services/RedditClient.cs new file mode 100644 index 0000000..b8a8ca8 --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/Services/RedditClient.cs @@ -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; + +/// +/// 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(); + } + } +} diff --git a/Jellyfin.Plugin.RedditComments/Services/RedditCommentsService.cs b/Jellyfin.Plugin.RedditComments/Services/RedditCommentsService.cs new file mode 100644 index 0000000..01ba3eb --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/Services/RedditCommentsService.cs @@ -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; + +/// +/// 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; +} diff --git a/Jellyfin.Plugin.RedditComments/Services/WebScriptInjector.cs b/Jellyfin.Plugin.RedditComments/Services/WebScriptInjector.cs new file mode 100644 index 0000000..5c56ec6 --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/Services/WebScriptInjector.cs @@ -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; + +/// +/// 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. +/// +public class WebScriptInjector : IHostedService +{ + private const string ScriptMarker = "RedditComments/Static/reddit-comments.js"; + + private readonly ILogger _logger; + private readonly IApplicationPaths _applicationPaths; + private readonly IServerConfigurationManager _configurationManager; + + /// + /// Initializes a new instance of the class. + /// + /// Logger. + /// Application paths. + /// Server configuration manager. + public WebScriptInjector( + ILogger logger, + IApplicationPaths applicationPaths, + IServerConfigurationManager configurationManager) + { + _logger = logger; + _applicationPaths = applicationPaths; + _configurationManager = configurationManager; + } + + /// + 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; + } + + /// + 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, "", 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("", 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); + } +} diff --git a/Jellyfin.Plugin.RedditComments/Web/reddit-comments.js b/Jellyfin.Plugin.RedditComments/Web/reddit-comments.js new file mode 100644 index 0000000..0f11df9 --- /dev/null +++ b/Jellyfin.Plugin.RedditComments/Web/reddit-comments.js @@ -0,0 +1,481 @@ +/* + * Jellyfin Reddit Comments plugin - web client script. + * Adds a "Reddit comments" button to the video player OSD which opens a sidebar + * with the episode's Reddit discussion thread. Nothing is fetched until the + * button is clicked. + */ +(function () { + 'use strict'; + + var BUTTON_ID = 'redditCommentsOsdButton'; + var SIDEBAR_ID = 'reddit-comments-sidebar'; + var STYLE_ID = 'reddit-comments-style'; + var ACCENT = '#a55aea'; + + var loadedItemId = null; + var sidebarOpen = false; + + // --------------------------------------------------------------------- + // API helpers + // --------------------------------------------------------------------- + + function getCredentials() { + // Primary path: the web client's global ApiClient. + if (window.ApiClient && typeof window.ApiClient.getUrl === 'function') { + var deviceId = window.ApiClient.deviceId; + if (typeof deviceId === 'function') { + deviceId = window.ApiClient.deviceId(); + } + return { + getUrl: function (path) { return window.ApiClient.getUrl(path); }, + token: window.ApiClient.accessToken ? window.ApiClient.accessToken() : null, + deviceId: deviceId || null + }; + } + + // Fallback: read stored credentials directly. + try { + var raw = localStorage.getItem('jellyfin_credentials') || localStorage.getItem('jellyfin-credentials'); + if (!raw) { + return null; + } + var creds = JSON.parse(raw); + var server = (creds.Servers || [])[0]; + if (!server || !server.AccessToken) { + return null; + } + var address = server.ManualAddress || server.LocalAddress; + if (!address) { + return null; + } + address = address.replace(/\/$/, ''); + return { + getUrl: function (path) { return address + '/' + path; }, + token: server.AccessToken, + deviceId: localStorage.getItem('_deviceId') || null + }; + } catch (e) { + return null; + } + } + + function apiFetch(path, options) { + var creds = getCredentials(); + if (!creds) { + return Promise.reject(new Error('Could not determine Jellyfin API credentials.')); + } + return fetch(creds.getUrl(path), { + method: (options && options.method) || 'GET', + headers: { 'X-Emby-Token': creds.token } + }).then(function (res) { + if (!res.ok) { + throw new Error('Request failed with HTTP ' + res.status); + } + return res.json(); + }); + } + + function getCurrentItemId() { + return apiFetch('Sessions?activeWithinSeconds=960').then(function (sessions) { + var creds = getCredentials(); + var session = null; + if (creds && creds.deviceId) { + session = sessions.find(function (s) { + return s.DeviceId === creds.deviceId && s.NowPlayingItem; + }); + } + if (!session) { + session = sessions.find(function (s) { return s.NowPlayingItem; }); + } + return session && session.NowPlayingItem ? session.NowPlayingItem.Id : null; + }); + } + + // --------------------------------------------------------------------- + // Formatting helpers + // --------------------------------------------------------------------- + + function timeAgo(date) { + var seconds = Math.floor((Date.now() - date.getTime()) / 1000); + if (seconds < 60) { return 'just now'; } + var minutes = Math.floor(seconds / 60); + if (minutes < 60) { return minutes + 'm ago'; } + var hours = Math.floor(minutes / 60); + if (hours < 24) { return hours + 'h ago'; } + var days = Math.floor(hours / 24); + if (days < 30) { return days + 'd ago'; } + var months = Math.floor(days / 30); + if (months < 12) { return months + 'mo ago'; } + return Math.floor(months / 12) + 'y ago'; + } + + function formatScore(score) { + if (score >= 1000) { + return (score / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; + } + return String(score); + } + + function el(tag, className, text) { + var node = document.createElement(tag); + if (className) { node.className = className; } + if (text !== undefined && text !== null) { node.textContent = text; } + return node; + } + + function svgIcon(pathData, size) { + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('width', String(size || 20)); + svg.setAttribute('height', String(size || 20)); + svg.setAttribute('fill', 'currentColor'); + var path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', pathData); + svg.appendChild(path); + return svg; + } + + var ICONS = { + chat: 'M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z', + close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z', + refresh: 'M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z', + upvote: 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z', + external: 'M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z' + }; + + // --------------------------------------------------------------------- + // Sidebar + // --------------------------------------------------------------------- + + function injectStyles() { + if (document.getElementById(STYLE_ID)) { + return; + } + var style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = [ + /* ---- panel ---- */ + '#' + SIDEBAR_ID + ' { position: fixed; top: 0; right: 0; height: 100%; width: min(440px, 94vw);', + ' background: #101010; color: #e8e8e8; z-index: 100000; display: flex; flex-direction: column;', + ' box-shadow: -8px 0 32px rgba(0,0,0,0.65);', + ' font-family: inherit; font-size: 14px;', + ' transform: translateX(105%); transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); }', + '#' + SIDEBAR_ID + '.rcs-open { transform: translateX(0); }', + + /* ---- header ---- */ + '#' + SIDEBAR_ID + ' .rcs-header { padding: 14px 16px 12px; background: #181818;', + ' border-bottom: 1px solid rgba(255,255,255,0.07); display: flex; align-items: flex-start; gap: 6px; }', + '#' + SIDEBAR_ID + ' .rcs-title-wrap { flex: 1; min-width: 0; }', + '#' + SIDEBAR_ID + ' .rcs-heading { display: flex; align-items: center; gap: 7px; font-weight: 700;', + ' font-size: 12px; text-transform: uppercase; letter-spacing: 0.09em; color: ' + ACCENT + '; margin-bottom: 7px; }', + '#' + SIDEBAR_ID + ' .rcs-thread-link { display: block; color: #fff; font-weight: 600; font-size: 14.5px;', + ' line-height: 1.35; text-decoration: none; margin-bottom: 8px; word-wrap: break-word; }', + '#' + SIDEBAR_ID + ' .rcs-thread-link:hover { color: ' + ACCENT + '; }', + '#' + SIDEBAR_ID + ' .rcs-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }', + '#' + SIDEBAR_ID + ' .rcs-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px;', + ' border-radius: 999px; font-size: 11.5px; font-weight: 600; line-height: 1.4;', + ' background: rgba(165,90,234,0.14); color: #cf9df5; }', + '#' + SIDEBAR_ID + ' .rcs-chip.rcs-chip-muted { background: rgba(255,255,255,0.06); color: #999; font-weight: 500; }', + '#' + SIDEBAR_ID + ' .rcs-chip svg { opacity: 0.85; }', + + /* ---- header buttons (jellyfin-style round icon buttons) ---- */ + '#' + SIDEBAR_ID + ' .rcs-icon-btn { flex-shrink: 0; width: 36px; height: 36px; display: inline-flex;', + ' align-items: center; justify-content: center; background: transparent; border: none; border-radius: 50%;', + ' color: #aaa; cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }', + '#' + SIDEBAR_ID + ' .rcs-icon-btn:hover { color: #fff; background: rgba(165,90,234,0.18); }', + '#' + SIDEBAR_ID + ' .rcs-icon-btn:active { background: rgba(165,90,234,0.3); }', + '#' + SIDEBAR_ID + ' .rcs-icon-btn.rcs-spinning svg { animation: rcs-spin 0.9s linear infinite; }', + + /* ---- body ---- */ + '#' + SIDEBAR_ID + ' .rcs-body { flex: 1; overflow-y: auto; padding: 8px 12px 40px; scrollbar-width: thin;', + ' scrollbar-color: #333 transparent; }', + '#' + SIDEBAR_ID + ' .rcs-body::-webkit-scrollbar { width: 8px; }', + '#' + SIDEBAR_ID + ' .rcs-body::-webkit-scrollbar-thumb { background: #2e2e2e; border-radius: 4px; }', + '#' + SIDEBAR_ID + ' .rcs-body::-webkit-scrollbar-thumb:hover { background: rgba(165,90,234,0.5); }', + + /* ---- empty / loading states ---- */ + '#' + SIDEBAR_ID + ' .rcs-state { display: flex; flex-direction: column; align-items: center;', + ' text-align: center; margin-top: 64px; padding: 0 24px; color: #888; line-height: 1.55; }', + '#' + SIDEBAR_ID + ' .rcs-state svg { color: rgba(165,90,234,0.45); margin-bottom: 14px; }', + '#' + SIDEBAR_ID + ' .rcs-spinner { margin: 64px auto 0; width: 36px; height: 36px;', + ' border: 3px solid rgba(165,90,234,0.15); border-top-color: ' + ACCENT + ';', + ' border-radius: 50%; animation: rcs-spin 0.9s linear infinite; }', + '@keyframes rcs-spin { to { transform: rotate(360deg); } }', + + /* ---- comments ---- */ + '#' + SIDEBAR_ID + ' .rcs-comment { margin: 2px 0; }', + '#' + SIDEBAR_ID + ' .rcs-comment-inner { padding: 8px 10px; border-radius: 8px;', + ' transition: background 0.12s ease; }', + '#' + SIDEBAR_ID + ' .rcs-comment-inner:hover { background: rgba(255,255,255,0.035); }', + '#' + SIDEBAR_ID + ' .rcs-comment-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px;', + ' font-size: 12px; color: #8b8b8b; margin-bottom: 4px; cursor: pointer; user-select: none; }', + '#' + SIDEBAR_ID + ' .rcs-comment-meta .rcs-author { color: #cf9df5; font-weight: 700; }', + '#' + SIDEBAR_ID + ' .rcs-comment-meta .rcs-score { display: inline-flex; align-items: center; gap: 1px; color: #b0b0b0; }', + '#' + SIDEBAR_ID + ' .rcs-comment-meta .rcs-score svg { color: ' + ACCENT + '; }', + '#' + SIDEBAR_ID + ' .rcs-comment-meta .rcs-dot { color: #4a4a4a; }', + '#' + SIDEBAR_ID + ' .rcs-comment-meta .rcs-collapse { margin-left: auto; padding: 0 8px; border-radius: 999px;', + ' font-size: 11px; font-weight: 600; color: #cf9df5; background: rgba(165,90,234,0.12); }', + '#' + SIDEBAR_ID + ' .rcs-comment-body { white-space: pre-wrap; word-wrap: break-word;', + ' overflow-wrap: anywhere; line-height: 1.5; color: #dcdcdc; }', + '#' + SIDEBAR_ID + ' .rcs-replies { margin-left: 6px; padding-left: 10px;', + ' border-left: 2px solid rgba(165,90,234,0.22); }', + '#' + SIDEBAR_ID + ' .rcs-replies .rcs-replies { border-left-color: rgba(165,90,234,0.13); }', + '#' + SIDEBAR_ID + ' .rcs-collapsed > .rcs-replies,', + '#' + SIDEBAR_ID + ' .rcs-collapsed > .rcs-comment-inner > .rcs-comment-body { display: none; }' + ].join('\n'); + document.head.appendChild(style); + } + + function getSidebar() { + var sidebar = document.getElementById(SIDEBAR_ID); + if (sidebar) { + return sidebar; + } + + injectStyles(); + sidebar = el('div'); + sidebar.id = SIDEBAR_ID; + + var header = el('div', 'rcs-header'); + var titleWrap = el('div', 'rcs-title-wrap'); + + var heading = el('div', 'rcs-heading'); + heading.appendChild(svgIcon(ICONS.chat, 15)); + heading.appendChild(document.createTextNode('Reddit Comments')); + titleWrap.appendChild(heading); + + var threadLink = el('a', 'rcs-thread-link'); + threadLink.target = '_blank'; + threadLink.rel = 'noopener noreferrer'; + titleWrap.appendChild(threadLink); + titleWrap.appendChild(el('div', 'rcs-chips')); + header.appendChild(titleWrap); + + var refreshBtn = el('button', 'rcs-icon-btn rcs-refresh'); + refreshBtn.title = 'Refresh comments'; + refreshBtn.appendChild(svgIcon(ICONS.refresh, 19)); + refreshBtn.addEventListener('click', function () { + if (loadedItemId && !refreshBtn.classList.contains('rcs-spinning')) { + refreshBtn.classList.add('rcs-spinning'); + loadComments(loadedItemId, true); + } + }); + header.appendChild(refreshBtn); + + var closeBtn = el('button', 'rcs-icon-btn rcs-close'); + closeBtn.title = 'Close'; + closeBtn.appendChild(svgIcon(ICONS.close, 19)); + closeBtn.addEventListener('click', closeSidebar); + header.appendChild(closeBtn); + + sidebar.appendChild(header); + sidebar.appendChild(el('div', 'rcs-body')); + document.body.appendChild(sidebar); + return sidebar; + } + + function setBodyContent(node) { + var body = getSidebar().querySelector('.rcs-body'); + body.innerHTML = ''; + body.appendChild(node); + } + + function showMessage(text) { + var state = el('div', 'rcs-state'); + state.appendChild(svgIcon(ICONS.chat, 44)); + state.appendChild(el('div', null, text)); + setBodyContent(state); + } + + function showSpinner() { + setBodyContent(el('div', 'rcs-spinner')); + } + + function renderComment(comment) { + var wrapper = el('div', 'rcs-comment'); + var inner = el('div', 'rcs-comment-inner'); + + var meta = el('div', 'rcs-comment-meta'); + meta.appendChild(el('span', 'rcs-author', comment.Author)); + + meta.appendChild(el('span', 'rcs-dot', '\u2022')); + var score = el('span', 'rcs-score'); + score.appendChild(svgIcon(ICONS.upvote, 13)); + score.appendChild(document.createTextNode(formatScore(comment.Score))); + meta.appendChild(score); + + meta.appendChild(el('span', 'rcs-dot', '\u2022')); + meta.appendChild(el('span', null, timeAgo(new Date(comment.CreatedUtc * 1000)))); + + inner.appendChild(meta); + inner.appendChild(el('div', 'rcs-comment-body', comment.Body)); + wrapper.appendChild(inner); + + if (comment.Replies && comment.Replies.length > 0) { + var collapse = el('span', 'rcs-collapse', '\u2212'); + meta.appendChild(collapse); + + var replies = el('div', 'rcs-replies'); + comment.Replies.forEach(function (reply) { + replies.appendChild(renderComment(reply)); + }); + wrapper.appendChild(replies); + + meta.addEventListener('click', function () { + wrapper.classList.toggle('rcs-collapsed'); + collapse.textContent = wrapper.classList.contains('rcs-collapsed') ? '+' : '\u2212'; + }); + } + + return wrapper; + } + + function makeChip(text, icon, muted) { + var chip = el('span', 'rcs-chip' + (muted ? ' rcs-chip-muted' : '')); + if (icon) { + chip.appendChild(svgIcon(icon, 12)); + } + chip.appendChild(document.createTextNode(text)); + return chip; + } + + function renderResponse(data) { + var sidebar = getSidebar(); + sidebar.querySelector('.rcs-refresh').classList.remove('rcs-spinning'); + var threadLink = sidebar.querySelector('.rcs-thread-link'); + var chips = sidebar.querySelector('.rcs-chips'); + chips.innerHTML = ''; + + if (!data.Found) { + threadLink.textContent = data.MediaLabel || ''; + threadLink.removeAttribute('href'); + showMessage(data.Message || 'No Reddit discussion thread was found for this title.'); + return; + } + + threadLink.textContent = data.ThreadTitle; + threadLink.href = 'https://www.reddit.com' + data.Permalink; + + chips.appendChild(makeChip('r/' + data.Subreddit, null, false)); + chips.appendChild(makeChip(formatScore(data.ThreadScore) + ' points', ICONS.upvote, false)); + chips.appendChild(makeChip(data.NumComments + ' comments', ICONS.chat, false)); + chips.appendChild(makeChip( + 'fetched ' + timeAgo(new Date(data.FetchedAt)) + (data.Cached ? ' \u2022 cached' : ''), + null, + true)); + + if (!data.Comments || data.Comments.length === 0) { + showMessage('The thread was found, but it has no comments to show.'); + return; + } + + var container = el('div'); + data.Comments.forEach(function (comment) { + container.appendChild(renderComment(comment)); + }); + setBodyContent(container); + } + + function loadComments(itemId, forceRefresh) { + showSpinner(); + var request = forceRefresh + ? apiFetch('RedditComments/Item/' + itemId + '/Refresh', { method: 'POST' }) + : apiFetch('RedditComments/Item/' + itemId); + + request.then(function (data) { + loadedItemId = itemId; + renderResponse(data); + }).catch(function (err) { + getSidebar().querySelector('.rcs-refresh').classList.remove('rcs-spinning'); + showMessage('Failed to load Reddit comments: ' + err.message); + }); + } + + function openSidebar() { + sidebarOpen = true; + getSidebar().classList.add('rcs-open'); + getCurrentItemId().then(function (itemId) { + if (!itemId) { + showMessage('Nothing is playing right now.'); + return; + } + if (itemId === loadedItemId) { + return; // already showing this item + } + loadComments(itemId, false); + }).catch(function (err) { + showMessage('Could not determine the currently playing item: ' + err.message); + }); + } + + function closeSidebar() { + sidebarOpen = false; + var sidebar = document.getElementById(SIDEBAR_ID); + if (sidebar) { + sidebar.classList.remove('rcs-open'); + } + } + + function toggleSidebar() { + if (sidebarOpen) { + closeSidebar(); + } else { + openSidebar(); + } + } + + // --------------------------------------------------------------------- + // OSD button + // --------------------------------------------------------------------- + + function findOsdControls() { + return document.querySelector('.videoOsdBottom .buttons') + || document.querySelector('.videoOsdBottom-maincontrols') + || document.querySelector('.videoOsdBottom'); + } + + function ensureButton() { + if (document.getElementById(BUTTON_ID)) { + return; + } + var controls = findOsdControls(); + if (!controls) { + return; + } + + var button = document.createElement('button'); + button.id = BUTTON_ID; + button.type = 'button'; + button.className = 'paper-icon-button-light autoSize'; + button.title = 'Reddit comments'; + var icon = svgIcon(ICONS.chat, 24); + icon.style.pointerEvents = 'none'; + button.appendChild(icon); + button.addEventListener('click', function (e) { + e.stopPropagation(); + toggleSidebar(); + }); + + var fullscreenBtn = controls.querySelector('.btnFullscreen'); + if (fullscreenBtn && fullscreenBtn.parentNode === controls) { + controls.insertBefore(button, fullscreenBtn); + } else { + controls.appendChild(button); + } + } + + function maybeAutoClose() { + // Close the sidebar when playback ends (the video OSD leaves the DOM). + if (sidebarOpen && !document.querySelector('.videoOsdBottom')) { + closeSidebar(); + } + } + + var observer = new MutationObserver(function () { + ensureButton(); + maybeAutoClose(); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + ensureButton(); +})(); diff --git a/README.md b/README.md new file mode 100644 index 0000000..161ffd3 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# Jellyfin Reddit Comments + +A Jellyfin server plugin that adds a **comments button to the video player**. Clicking it opens a +sidebar showing the Reddit discussion thread for the episode or movie you're watching — built +primarily for anime, where almost every episode has a thread on r/anime. + +- **Lazy**: Reddit is only contacted when you click the button. No background searching. +- **Cached**: threads and comments are stored in a local SQLite database, so re-watching an + episode (or everyone on your server watching the same one) costs zero extra API calls. +- **Rate limited**: a sliding-window limiter caps Reddit API usage at 60 requests/minute + (configurable, hard-capped at Reddit's own 100/min limit). In practice a fresh episode lookup + costs ~2–4 requests. + +> **A note on Devvit:** this plugin does *not* use Devvit, Reddit's developer platform. Devvit apps +> run sandboxed *on Reddit's own servers* and cannot be embedded in external software like a +> Jellyfin plugin. Instead, the plugin talks to the **Reddit REST API** directly using OAuth2 +> app-only (client credentials) authentication. + +## Requirements + +- Jellyfin Server **10.11.x** (built against the 10.11.11 ABI, `net9.0`) +- The Jellyfin **web client** (the button/sidebar is injected into jellyfin-web; the API works + from any client that can call the plugin endpoints) +- A free Reddit "script" app (client id + secret) + +## 1. Create the Reddit app (one time) + +1. Go to and click **create app**. +2. Pick any name, choose type **script**, set redirect uri to `http://localhost`. +3. Note the **client id** (shown under the app name) and the **secret**. + +## 2. Install the plugin + +### Option A — manual install + +1. Download/copy `dist/Jellyfin.Plugin.RedditComments_1.0.0.0.zip`. +2. Create a folder `RedditComments` inside your Jellyfin plugins directory: + - Linux (native): `/var/lib/jellyfin/plugins/RedditComments` + - Docker: `/plugins/RedditComments` + - Windows: `%ProgramData%\Jellyfin\Server\plugins\RedditComments` +3. Extract the zip contents into that folder (the DLLs must sit directly inside it). +4. Restart Jellyfin. + +### Option B — plugin repository + +1. Host `dist/Jellyfin.Plugin.RedditComments_1.0.0.0.zip` somewhere reachable by your server + (e.g. a GitHub release asset). +2. Edit `manifest.json`: set `sourceUrl` to the zip URL and update `checksum` with the zip's MD5 + (`md5sum Jellyfin.Plugin.RedditComments_1.0.0.0.zip`). +3. Host `manifest.json` next to the zip. +4. In Jellyfin: **Dashboard → Plugins → Repositories → +**, paste the manifest URL, then install + "Reddit Comments" from the catalog and restart. + +## 3. Configure + +**Dashboard → Plugins → Reddit Comments**: + +1. Enter your **Client ID** and **Client Secret**, and set a descriptive **User Agent** + (Reddit requires one, e.g. `linux:jellyfin-reddit-comments:v1.0 (by /u/yourname)`). +2. Click **Test connection** to verify. +3. Optionally adjust subreddits (default `anime`), cache durations, rate limit, and comment + filters, then **Save**. + +## Usage + +1. Play an episode in the Jellyfin web player. +2. Click the new **chat bubble** button in the player controls (next to fullscreen). +3. The sidebar opens with the matching Reddit thread: title (links to Reddit), subreddit/score/ + comment-count chips, and the comment tree. Click a comment's meta line to collapse its replies. +4. Use the **refresh** button in the sidebar header to bypass the cache and fetch fresh comments. + +Nothing is searched or fetched until the button is clicked. + +## How it works + +- **Finding the thread**: the plugin searches your configured subreddits for + `"" episode ` (plus season/original-title variants) and scores candidates by + episode-number match and title similarity. Results are cached per Jellyfin item. +- **Caching**: SQLite at `/plugins/RedditComments/reddit-comments.db`. Found threads are + cached for 30 days, "not found" results for 24 hours (both configurable). +- **Rate limiting**: every Reddit request (including token refreshes) passes through a + sliding-window limiter — never more than the configured number per rolling 60 seconds. +- **Web client injection**: at startup the plugin adds a `