init
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -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
|
||||
170
Jellyfin.Plugin.RedditComments/Api/RedditCommentsController.cs
Normal file
170
Jellyfin.Plugin.RedditComments/Api/RedditCommentsController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for the Reddit Comments plugin.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("RedditComments")]
|
||||
public class RedditCommentsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<RedditCommentsController> _logger;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly RedditCommentsService _commentsService;
|
||||
private readonly RedditClient _redditClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedditCommentsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
/// <param name="commentsService">Comments service.</param>
|
||||
/// <param name="redditClient">Reddit client.</param>
|
||||
public RedditCommentsController(
|
||||
ILogger<RedditCommentsController> logger,
|
||||
ILibraryManager libraryManager,
|
||||
RedditCommentsService commentsService,
|
||||
RedditClient redditClient)
|
||||
{
|
||||
_logger = logger;
|
||||
_libraryManager = libraryManager;
|
||||
_commentsService = commentsService;
|
||||
_redditClient = redditClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Reddit discussion comments for a Jellyfin item. Served from the local cache
|
||||
/// unless this is the first lookup or the cache expired.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The Jellyfin item id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The comments response.</returns>
|
||||
[HttpGet("Item/{itemId}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CommentsResponse>> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces a fresh lookup for an item, bypassing and replacing the cache.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The Jellyfin item id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The comments response.</returns>
|
||||
[HttpPost("Item/{itemId}/Refresh")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CommentsResponse>> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the configured Reddit API credentials. Admin only.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Whether the credentials work.</returns>
|
||||
[HttpGet("Test")]
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<object>> 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 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the web client script that adds the comments button/sidebar to the player.
|
||||
/// </summary>
|
||||
/// <returns>The JavaScript file.</returns>
|
||||
[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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.RedditComments.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin configuration.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Reddit app client id (create a "script" app at https://www.reddit.com/prefs/apps).
|
||||
/// </summary>
|
||||
public string RedditClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Reddit app client secret.
|
||||
/// </summary>
|
||||
public string RedditClientSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the User-Agent sent to Reddit. Reddit requires a unique, descriptive value.
|
||||
/// </summary>
|
||||
public string RedditUserAgent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the comma-separated list of subreddits to search, in order (without the r/ prefix).
|
||||
/// </summary>
|
||||
public string Subreddits { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how many days a found thread is cached before it is fetched again.
|
||||
/// </summary>
|
||||
public int CacheDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how many hours a "no thread found" result is cached.
|
||||
/// </summary>
|
||||
public int NotFoundCacheHours { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of Reddit API requests per minute (Reddit's own limit is 100).
|
||||
/// </summary>
|
||||
public int MaxRequestsPerMinute { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of comments requested from Reddit per thread.
|
||||
/// </summary>
|
||||
public int MaxComments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum reply depth requested from Reddit.
|
||||
/// </summary>
|
||||
public int CommentDepth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum score a comment must have to be shown.
|
||||
/// </summary>
|
||||
public int MinScore { get; set; }
|
||||
}
|
||||
168
Jellyfin.Plugin.RedditComments/Configuration/configPage.html
Normal file
168
Jellyfin.Plugin.RedditComments/Configuration/configPage.html
Normal file
@@ -0,0 +1,168 @@
|
||||
<div id="RedditCommentsConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<style>
|
||||
#RedditCommentsConfigPage .rc-accent { color: #a55aea; }
|
||||
#RedditCommentsConfigPage .rc-info {
|
||||
border-left: 3px solid #a55aea;
|
||||
background: rgba(165, 90, 234, 0.08);
|
||||
border-radius: 0.4em;
|
||||
padding: 1em 1.2em;
|
||||
margin-bottom: 1.5em;
|
||||
line-height: 1.55;
|
||||
}
|
||||
#RedditCommentsConfigPage .rc-info ol { margin: 0.5em 0 0; padding-left: 1.4em; }
|
||||
#RedditCommentsConfigPage .rc-info code {
|
||||
background: rgba(165, 90, 234, 0.15);
|
||||
border-radius: 0.3em;
|
||||
padding: 0.1em 0.4em;
|
||||
}
|
||||
#RedditCommentsConfigPage .rc-section {
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
margin: 1.6em 0 0.6em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
#RedditCommentsConfigPage .rc-test-result { margin-left: 1em; font-weight: 600; }
|
||||
</style>
|
||||
|
||||
<h2 style="margin-top:0;">Reddit Comments <span class="rc-accent" id="rcVersion"></span></h2>
|
||||
|
||||
<div class="rc-info">
|
||||
<strong>Reddit API setup (one time):</strong>
|
||||
<ol>
|
||||
<li>Go to <a href="https://www.reddit.com/prefs/apps" target="_blank" rel="noopener" class="rc-accent">reddit.com/prefs/apps</a> and click <em>create app</em>.</li>
|
||||
<li>Choose type <code>script</code>, any name, and <code>http://localhost</code> as the redirect uri.</li>
|
||||
<li>Copy the <strong>client id</strong> (under the app name) and the <strong>secret</strong> into the fields below.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<form id="RedditCommentsConfigForm">
|
||||
<div class="rc-section rc-accent">Reddit API</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RedditClientId">Client ID</label>
|
||||
<input is="emby-input" type="text" id="RedditClientId" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RedditClientSecret">Client Secret</label>
|
||||
<input is="emby-input" type="password" id="RedditClientSecret" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RedditUserAgent">User Agent</label>
|
||||
<input is="emby-input" type="text" id="RedditUserAgent" />
|
||||
<div class="fieldDescription">Reddit requires a unique, descriptive user agent, e.g. <code>linux:jellyfin-reddit-comments:v1.0 (by /u/yourname)</code>.</div>
|
||||
</div>
|
||||
<button is="emby-button" type="button" id="rcTestButton" class="raised button-block">
|
||||
<span>Test connection</span>
|
||||
</button>
|
||||
<span id="rcTestResult" class="rc-test-result"></span>
|
||||
|
||||
<div class="rc-section rc-accent">Search</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="Subreddits">Subreddits</label>
|
||||
<input is="emby-input" type="text" id="Subreddits" />
|
||||
<div class="fieldDescription">Comma-separated, without the r/ prefix. Searched in order. Default: <code>anime</code></div>
|
||||
</div>
|
||||
|
||||
<div class="rc-section rc-accent">Caching & rate limits</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="CacheDays">Cache found threads for (days)</label>
|
||||
<input is="emby-input" type="number" id="CacheDays" min="1" max="365" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="NotFoundCacheHours">Cache "not found" results for (hours)</label>
|
||||
<input is="emby-input" type="number" id="NotFoundCacheHours" min="1" max="720" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="MaxRequestsPerMinute">Max Reddit requests per minute</label>
|
||||
<input is="emby-input" type="number" id="MaxRequestsPerMinute" min="1" max="100" />
|
||||
<div class="fieldDescription">Hard cap enforced by the plugin. Reddit's own limit is 100/min; 60 is a safe default.</div>
|
||||
</div>
|
||||
|
||||
<div class="rc-section rc-accent">Comments</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="MaxComments">Max comments per thread</label>
|
||||
<input is="emby-input" type="number" id="MaxComments" min="1" max="500" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="CommentDepth">Max reply depth</label>
|
||||
<input is="emby-input" type="number" id="CommentDepth" min="1" max="10" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="MinScore">Minimum comment score</label>
|
||||
<input is="emby-input" type="number" id="MinScore" min="-100" max="1000" />
|
||||
<div class="fieldDescription">Comments below this score are hidden. 1 filters out downvoted comments.</div>
|
||||
</div>
|
||||
|
||||
<button is="emby-button" type="submit" class="raised button-submit block">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
var pluginId = '7b2f1c4e-9a3d-4f6b-8c5e-2d1a0f9e7b6c';
|
||||
var page = document.querySelector('#RedditCommentsConfigPage');
|
||||
|
||||
page.addEventListener('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
||||
page.querySelector('#RedditClientId').value = config.RedditClientId || '';
|
||||
page.querySelector('#RedditClientSecret').value = config.RedditClientSecret || '';
|
||||
page.querySelector('#RedditUserAgent').value = config.RedditUserAgent || '';
|
||||
page.querySelector('#Subreddits').value = config.Subreddits || 'anime';
|
||||
page.querySelector('#CacheDays').value = config.CacheDays;
|
||||
page.querySelector('#NotFoundCacheHours').value = config.NotFoundCacheHours;
|
||||
page.querySelector('#MaxRequestsPerMinute').value = config.MaxRequestsPerMinute;
|
||||
page.querySelector('#MaxComments').value = config.MaxComments;
|
||||
page.querySelector('#CommentDepth').value = config.CommentDepth;
|
||||
page.querySelector('#MinScore').value = config.MinScore;
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
|
||||
page.querySelector('#rcTestButton').addEventListener('click', function () {
|
||||
var result = page.querySelector('#rcTestResult');
|
||||
result.textContent = 'Testing...';
|
||||
result.style.color = '#aaa';
|
||||
ApiClient.getJSON(ApiClient.getUrl('RedditComments/Test')).then(function (response) {
|
||||
result.textContent = response.message;
|
||||
result.style.color = response.ok ? '#a55aea' : '#e57373';
|
||||
}).catch(function (err) {
|
||||
result.textContent = 'Test failed: ' + (err && err.message ? err.message : 'unknown error');
|
||||
result.style.color = '#e57373';
|
||||
});
|
||||
});
|
||||
|
||||
page.querySelector('#RedditCommentsConfigForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
||||
config.RedditClientId = page.querySelector('#RedditClientId').value.trim();
|
||||
config.RedditClientSecret = page.querySelector('#RedditClientSecret').value.trim();
|
||||
config.RedditUserAgent = page.querySelector('#RedditUserAgent').value.trim();
|
||||
config.Subreddits = page.querySelector('#Subreddits').value.trim();
|
||||
config.CacheDays = parseInt(page.querySelector('#CacheDays').value, 10) || 30;
|
||||
config.NotFoundCacheHours = parseInt(page.querySelector('#NotFoundCacheHours').value, 10) || 24;
|
||||
config.MaxRequestsPerMinute = parseInt(page.querySelector('#MaxRequestsPerMinute').value, 10) || 60;
|
||||
config.MaxComments = parseInt(page.querySelector('#MaxComments').value, 10) || 150;
|
||||
config.CommentDepth = parseInt(page.querySelector('#CommentDepth').value, 10) || 8;
|
||||
config.MinScore = parseInt(page.querySelector('#MinScore').value, 10);
|
||||
if (isNaN(config.MinScore)) { config.MinScore = 1; }
|
||||
|
||||
ApiClient.updatePluginConfiguration(pluginId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
return false;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.RedditComments</RootNamespace>
|
||||
<AssemblyName>Jellyfin.Plugin.RedditComments</AssemblyName>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.11">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.11.11">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.18" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
<None Remove="Web\reddit-comments.js" />
|
||||
<EmbeddedResource Include="Web\reddit-comments.js" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
97
Jellyfin.Plugin.RedditComments/Models/Dtos.cs
Normal file
97
Jellyfin.Plugin.RedditComments/Models/Dtos.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.RedditComments.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A single Reddit comment with nested replies.
|
||||
/// </summary>
|
||||
public class CommentDto
|
||||
{
|
||||
/// <summary>Gets or sets the comment author.</summary>
|
||||
public string Author { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the comment body (plain text / markdown source).</summary>
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the comment score.</summary>
|
||||
public int Score { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the creation time (unix seconds, UTC).</summary>
|
||||
public long CreatedUtc { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the nested replies.</summary>
|
||||
public List<CommentDto> Replies { get; set; } = new List<CommentDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response returned by the plugin API for a Jellyfin item.
|
||||
/// </summary>
|
||||
public class CommentsResponse
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether a Reddit thread was found.</summary>
|
||||
public bool Found { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the Jellyfin item id.</summary>
|
||||
public string ItemId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets a human-readable label for the media (e.g. "Show — Episode 5").</summary>
|
||||
public string MediaLabel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the Reddit thread id.</summary>
|
||||
public string ThreadId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the Reddit thread title.</summary>
|
||||
public string ThreadTitle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the subreddit.</summary>
|
||||
public string Subreddit { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the thread permalink path.</summary>
|
||||
public string Permalink { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the thread score.</summary>
|
||||
public int ThreadScore { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the thread's total comment count.</summary>
|
||||
public int NumComments { get; set; }
|
||||
|
||||
/// <summary>Gets or sets when the data was fetched from Reddit (ISO 8601, UTC).</summary>
|
||||
public string FetchedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the response came from the local cache.</summary>
|
||||
public bool Cached { get; set; }
|
||||
|
||||
/// <summary>Gets or sets an informational message (e.g. why nothing was found).</summary>
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the top-level comments.</summary>
|
||||
public List<CommentDto> Comments { get; set; } = new List<CommentDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Reddit thread candidate found via search.
|
||||
/// </summary>
|
||||
public class ThreadCandidate
|
||||
{
|
||||
/// <summary>Gets or sets the thread id.</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the thread title.</summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the subreddit.</summary>
|
||||
public string Subreddit { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the permalink path.</summary>
|
||||
public string Permalink { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the thread score.</summary>
|
||||
public int Score { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the thread's comment count.</summary>
|
||||
public int NumComments { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the link flair text, if any.</summary>
|
||||
public string LinkFlairText { get; set; } = string.Empty;
|
||||
}
|
||||
59
Jellyfin.Plugin.RedditComments/Plugin.cs
Normal file
59
Jellyfin.Plugin.RedditComments/Plugin.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The main plugin.
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// The plugin GUID. Must match the id used in the config page and repository manifest.
|
||||
/// </summary>
|
||||
public const string PluginGuid = "7b2f1c4e-9a3d-4f6b-8c5e-2d1a0f9e7b6c";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Reddit Comments";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Shows Reddit discussion threads (e.g. r/anime episode threads) in the video player.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse(PluginGuid);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current plugin instance.
|
||||
/// </summary>
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return
|
||||
[
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
34
Jellyfin.Plugin.RedditComments/PluginServiceRegistrator.cs
Normal file
34
Jellyfin.Plugin.RedditComments/PluginServiceRegistrator.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the plugin's services in the Jellyfin DI container.
|
||||
/// </summary>
|
||||
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
var dbPath = Path.Combine(
|
||||
applicationHost.Resolve<IApplicationPaths>().PluginConfigurationsPath,
|
||||
"RedditComments",
|
||||
"reddit-comments.db");
|
||||
|
||||
serviceCollection.AddSingleton<RateLimiter>();
|
||||
serviceCollection.AddSingleton(sp => new RedditClient(sp.GetRequiredService<ILogger<RedditClient>>(), sp.GetRequiredService<RateLimiter>()));
|
||||
serviceCollection.AddSingleton(sp => new CommentCache(dbPath, sp.GetRequiredService<ILogger<CommentCache>>()));
|
||||
serviceCollection.AddSingleton(sp => new RedditCommentsService(
|
||||
sp.GetRequiredService<ILogger<RedditCommentsService>>(),
|
||||
sp.GetRequiredService<RedditClient>(),
|
||||
sp.GetRequiredService<CommentCache>()));
|
||||
|
||||
serviceCollection.AddHostedService<WebScriptInjector>();
|
||||
}
|
||||
}
|
||||
210
Jellyfin.Plugin.RedditComments/Services/CommentCache.cs
Normal file
210
Jellyfin.Plugin.RedditComments/Services/CommentCache.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.RedditComments.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One cached lookup for a Jellyfin item.
|
||||
/// </summary>
|
||||
public class CachedThread
|
||||
{
|
||||
/// <summary>Gets or sets the Jellyfin item id.</summary>
|
||||
public string ItemId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the Reddit thread id (empty when nothing was found).</summary>
|
||||
public string ThreadId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the Reddit thread title.</summary>
|
||||
public string ThreadTitle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the subreddit the thread was found in.</summary>
|
||||
public string Subreddit { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the thread permalink path.</summary>
|
||||
public string Permalink { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the thread score.</summary>
|
||||
public int ThreadScore { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the thread's total comment count.</summary>
|
||||
public int NumComments { get; set; }
|
||||
|
||||
/// <summary>Gets or sets when this entry was fetched (UTC).</summary>
|
||||
public DateTime FetchedAt { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the serialized comments JSON array.</summary>
|
||||
public string CommentsJson { get; set; } = "[]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-backed cache of Reddit threads/comments for Jellyfin items.
|
||||
/// Avoids hitting the Reddit API again for episodes that were already looked up.
|
||||
/// </summary>
|
||||
public class CommentCache
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
private readonly ILogger<CommentCache> _logger;
|
||||
private readonly object _lock = new object();
|
||||
private bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CommentCache"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbPath">Full path of the SQLite database file.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public CommentCache(string dbPath, ILogger<CommentCache> logger)
|
||||
{
|
||||
_dbPath = dbPath;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached entry if it exists and is still fresh.
|
||||
/// </summary>
|
||||
/// <param name="itemId">Jellyfin item id.</param>
|
||||
/// <param name="foundTtl">Freshness window for successful lookups.</param>
|
||||
/// <param name="notFoundTtl">Freshness window for negative lookups.</param>
|
||||
/// <returns>The cached entry, or null if missing/stale.</returns>
|
||||
public CachedThread? Get(string itemId, TimeSpan foundTtl, TimeSpan notFoundTtl)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
using var connection = OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT thread_id, thread_title, subreddit, permalink, thread_score, num_comments, fetched_at, comments_json FROM threads WHERE item_id = $itemId";
|
||||
command.Parameters.AddWithValue("$itemId", itemId);
|
||||
|
||||
using var reader = command.ExecuteReader();
|
||||
if (!reader.Read())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var entry = new CachedThread
|
||||
{
|
||||
ItemId = itemId,
|
||||
ThreadId = reader.GetString(0),
|
||||
ThreadTitle = reader.GetString(1),
|
||||
Subreddit = reader.GetString(2),
|
||||
Permalink = reader.GetString(3),
|
||||
ThreadScore = reader.GetInt32(4),
|
||||
NumComments = reader.GetInt32(5),
|
||||
FetchedAt = DateTime.Parse(reader.GetString(6), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
|
||||
CommentsJson = reader.GetString(7)
|
||||
};
|
||||
|
||||
var found = entry.ThreadId.Length > 0;
|
||||
var ttl = found ? foundTtl : notFoundTtl;
|
||||
if (DateTime.UtcNow - entry.FetchedAt > ttl)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores or replaces the entry for an item. Stamps <see cref="CachedThread.FetchedAt"/> with the current time.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry to store.</param>
|
||||
public void Set(CachedThread entry)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureInitialized();
|
||||
entry.FetchedAt = DateTime.UtcNow;
|
||||
|
||||
using var connection = OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO threads (item_id, thread_id, thread_title, subreddit, permalink, thread_score, num_comments, fetched_at, comments_json)
|
||||
VALUES ($itemId, $threadId, $threadTitle, $subreddit, $permalink, $threadScore, $numComments, $fetchedAt, $commentsJson)
|
||||
ON CONFLICT(item_id) DO UPDATE SET
|
||||
thread_id = $threadId,
|
||||
thread_title = $threadTitle,
|
||||
subreddit = $subreddit,
|
||||
permalink = $permalink,
|
||||
thread_score = $threadScore,
|
||||
num_comments = $numComments,
|
||||
fetched_at = $fetchedAt,
|
||||
comments_json = $commentsJson";
|
||||
command.Parameters.AddWithValue("$itemId", entry.ItemId);
|
||||
command.Parameters.AddWithValue("$threadId", entry.ThreadId);
|
||||
command.Parameters.AddWithValue("$threadTitle", entry.ThreadTitle);
|
||||
command.Parameters.AddWithValue("$subreddit", entry.Subreddit);
|
||||
command.Parameters.AddWithValue("$permalink", entry.Permalink);
|
||||
command.Parameters.AddWithValue("$threadScore", entry.ThreadScore);
|
||||
command.Parameters.AddWithValue("$numComments", entry.NumComments);
|
||||
command.Parameters.AddWithValue("$fetchedAt", entry.FetchedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
command.Parameters.AddWithValue("$commentsJson", entry.CommentsJson);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the cached entry for an item (used by manual refresh).
|
||||
/// </summary>
|
||||
/// <param name="itemId">Jellyfin item id.</param>
|
||||
public void Invalidate(string itemId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
using var connection = OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "DELETE FROM threads WHERE item_id = $itemId";
|
||||
command.Parameters.AddWithValue("$itemId", itemId);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureInitialized()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(_dbPath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
using var connection = OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
item_id TEXT PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL,
|
||||
thread_title TEXT NOT NULL,
|
||||
subreddit TEXT NOT NULL,
|
||||
permalink TEXT NOT NULL,
|
||||
thread_score INTEGER NOT NULL,
|
||||
num_comments INTEGER NOT NULL,
|
||||
fetched_at TEXT NOT NULL,
|
||||
comments_json TEXT NOT NULL
|
||||
)";
|
||||
command.ExecuteNonQuery();
|
||||
|
||||
_initialized = true;
|
||||
_logger.LogInformation("Reddit comments cache initialized at {Path}", _dbPath);
|
||||
}
|
||||
|
||||
private SqliteConnection OpenConnection()
|
||||
{
|
||||
var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadWriteCreate");
|
||||
connection.Open();
|
||||
using var pragma = connection.CreateCommand();
|
||||
pragma.CommandText = "PRAGMA journal_mode=WAL";
|
||||
pragma.ExecuteNonQuery();
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
78
Jellyfin.Plugin.RedditComments/Services/RateLimiter.cs
Normal file
78
Jellyfin.Plugin.RedditComments/Services/RateLimiter.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.RedditComments.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Sliding-window rate limiter. Guarantees no more than N requests per rolling 60 seconds,
|
||||
/// no matter how many callers are waiting.
|
||||
/// </summary>
|
||||
public class RateLimiter
|
||||
{
|
||||
private readonly object _lock = new object();
|
||||
private readonly Queue<DateTime> _requestTimestamps = new Queue<DateTime>();
|
||||
private readonly int? _maxPerMinuteOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RateLimiter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxPerMinuteOverride">Optional fixed limit (used by tests); otherwise the plugin configuration is used.</param>
|
||||
public RateLimiter(int? maxPerMinuteOverride = null)
|
||||
{
|
||||
_maxPerMinuteOverride = maxPerMinuteOverride;
|
||||
}
|
||||
|
||||
private int MaxPerMinute
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_maxPerMinuteOverride.HasValue)
|
||||
{
|
||||
return _maxPerMinuteOverride.Value;
|
||||
}
|
||||
|
||||
return ReadConfiguredLimit();
|
||||
}
|
||||
}
|
||||
|
||||
private static int ReadConfiguredLimit()
|
||||
{
|
||||
var configured = Plugin.Instance?.Configuration.MaxRequestsPerMinute ?? 60;
|
||||
// Reddit's own hard limit for OAuth clients is 100 req/min; never exceed it.
|
||||
return Math.Clamp(configured, 1, 100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until a request slot is available, then reserves it.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that completes when the caller may perform one request.</returns>
|
||||
public async Task WaitAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
TimeSpan delay;
|
||||
lock (_lock)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
while (_requestTimestamps.Count > 0 && now - _requestTimestamps.Peek() >= TimeSpan.FromMinutes(1))
|
||||
{
|
||||
_requestTimestamps.Dequeue();
|
||||
}
|
||||
|
||||
if (_requestTimestamps.Count < MaxPerMinute)
|
||||
{
|
||||
_requestTimestamps.Enqueue(now);
|
||||
return;
|
||||
}
|
||||
|
||||
// Window is full: wait until the oldest request falls out of the window.
|
||||
delay = TimeSpan.FromMinutes(1) - (now - _requestTimestamps.Peek()) + TimeSpan.FromMilliseconds(100);
|
||||
}
|
||||
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
173
Jellyfin.Plugin.RedditComments/Services/RedditClient.cs
Normal file
173
Jellyfin.Plugin.RedditComments/Services/RedditClient.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.RedditComments.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Reddit REST API client using the OAuth2 "client credentials" (app-only) flow.
|
||||
/// Every request goes through the <see cref="RateLimiter"/>.
|
||||
/// </summary>
|
||||
public class RedditClient
|
||||
{
|
||||
private const string TokenUrl = "https://www.reddit.com/api/v1/access_token";
|
||||
private const string OAuthBaseUrl = "https://oauth.reddit.com";
|
||||
|
||||
private readonly ILogger<RedditClient> _logger;
|
||||
private readonly RateLimiter _rateLimiter;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SemaphoreSlim _tokenLock = new SemaphoreSlim(1, 1);
|
||||
|
||||
private string? _accessToken;
|
||||
private DateTime _tokenExpiresAt = DateTime.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedditClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="rateLimiter">Rate limiter.</param>
|
||||
public RedditClient(ILogger<RedditClient> logger, RateLimiter rateLimiter)
|
||||
{
|
||||
_logger = logger;
|
||||
_rateLimiter = rateLimiter;
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
private static string UserAgent
|
||||
{
|
||||
get
|
||||
{
|
||||
var ua = Plugin.Instance?.Configuration.RedditUserAgent;
|
||||
return string.IsNullOrWhiteSpace(ua)
|
||||
? "linux:jellyfin-plugin-reddit-comments:v1.0.0 (by /u/unknown)"
|
||||
: ua;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the user has configured Reddit API credentials.
|
||||
/// </summary>
|
||||
public static bool IsConfigured
|
||||
{
|
||||
get
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
return config is not null
|
||||
&& !string.IsNullOrWhiteSpace(config.RedditClientId)
|
||||
&& !string.IsNullOrWhiteSpace(config.RedditClientSecret);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs a rate-limited, authenticated GET against oauth.reddit.com and returns the JSON body.
|
||||
/// </summary>
|
||||
/// <param name="pathAndQuery">Path and query, e.g. /r/anime/search.json?q=... .</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The parsed JSON document.</returns>
|
||||
public async Task<JsonDocument> GetJsonAsync(string pathAndQuery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
await _rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, OAuthBaseUrl + pathAndQuery);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Headers.UserAgent.ParseAdd(UserAgent);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.Headers.Contains("x-ratelimit-remaining"))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Reddit rate limit: used={Used} remaining={Remaining} reset={Reset}s",
|
||||
response.Headers.TryGetValues("x-ratelimit-used", out var used) ? string.Join(',', used) : "?",
|
||||
response.Headers.TryGetValues("x-ratelimit-remaining", out var remaining) ? string.Join(',', remaining) : "?",
|
||||
response.Headers.TryGetValues("x-ratelimit-reset", out var reset) ? string.Join(',', reset) : "?");
|
||||
}
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && attempt == 0)
|
||||
{
|
||||
// Token rejected: force a refresh and retry once.
|
||||
_logger.LogWarning("Reddit returned 401, refreshing access token");
|
||||
token = await GetAccessTokenAsync(cancellationToken, forceRefresh: true).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
throw new HttpRequestException("Reddit request failed after token refresh");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the configured credentials can obtain an access token.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if a token was obtained.</returns>
|
||||
public async Task<bool> TestCredentialsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await GetAccessTokenAsync(cancellationToken, forceRefresh: true).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(_accessToken);
|
||||
}
|
||||
|
||||
private async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken, bool forceRefresh = false)
|
||||
{
|
||||
await _tokenLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (!forceRefresh
|
||||
&& !string.IsNullOrEmpty(_accessToken)
|
||||
&& DateTime.UtcNow < _tokenExpiresAt)
|
||||
{
|
||||
return _accessToken;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config is null || string.IsNullOrWhiteSpace(config.RedditClientId) || string.IsNullOrWhiteSpace(config.RedditClientSecret))
|
||||
{
|
||||
throw new InvalidOperationException("Reddit API credentials are not configured. Open Dashboard → Plugins → Reddit Comments and enter a client id and secret.");
|
||||
}
|
||||
|
||||
await _rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, TokenUrl);
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(config.RedditClientId.Trim() + ":" + config.RedditClientSecret.Trim()));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
request.Headers.UserAgent.ParseAdd(UserAgent);
|
||||
request.Content = new FormUrlEncodedContent(
|
||||
[
|
||||
new KeyValuePair<string, string>("grant_type", "client_credentials")
|
||||
]);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new HttpRequestException($"Reddit token request failed with {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_accessToken = doc.RootElement.GetProperty("access_token").GetString();
|
||||
var expiresIn = doc.RootElement.TryGetProperty("expires_in", out var exp) ? exp.GetInt32() : 3600;
|
||||
_tokenExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn - 60);
|
||||
|
||||
_logger.LogInformation("Obtained Reddit access token, valid for {Seconds}s", expiresIn);
|
||||
return _accessToken!;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tokenLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
454
Jellyfin.Plugin.RedditComments/Services/RedditCommentsService.cs
Normal file
454
Jellyfin.Plugin.RedditComments/Services/RedditCommentsService.cs
Normal file
@@ -0,0 +1,454 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.RedditComments.Models;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.RedditComments.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Finds the Reddit discussion thread for a Jellyfin item and fetches its comments.
|
||||
/// Results are cached in SQLite so the Reddit API is only hit on the first request per item.
|
||||
/// </summary>
|
||||
public class RedditCommentsService
|
||||
{
|
||||
private static readonly HashSet<string> Stopwords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"the", "a", "an", "of", "and", "or", "to", "in", "on", "at", "is", "it", "no", "wa", "ga", "ni", "wo", "o", "e", "de", "season", "part", "cour"
|
||||
};
|
||||
|
||||
private readonly ILogger<RedditCommentsService> _logger;
|
||||
private readonly RedditClient _redditClient;
|
||||
private readonly CommentCache _cache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedditCommentsService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="redditClient">Reddit API client.</param>
|
||||
/// <param name="cache">Comment cache.</param>
|
||||
public RedditCommentsService(ILogger<RedditCommentsService> logger, RedditClient redditClient, CommentCache cache)
|
||||
{
|
||||
_logger = logger;
|
||||
_redditClient = redditClient;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Reddit comments for a Jellyfin item, using the cache when possible.
|
||||
/// </summary>
|
||||
/// <param name="item">The Jellyfin item (episode or movie).</param>
|
||||
/// <param name="forceRefresh">Bypass the cache and fetch fresh data.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The comments response.</returns>
|
||||
public async Task<CommentsResponse> GetForItemAsync(BaseItem item, bool forceRefresh, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = Plugin.Instance!.Configuration;
|
||||
var itemId = item.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
var mediaLabel = BuildMediaLabel(item);
|
||||
|
||||
if (item is not Episode && item is not Movie)
|
||||
{
|
||||
return new CommentsResponse
|
||||
{
|
||||
Found = false,
|
||||
ItemId = itemId,
|
||||
MediaLabel = mediaLabel,
|
||||
Message = "Reddit comments are only supported for episodes and movies."
|
||||
};
|
||||
}
|
||||
|
||||
if (!forceRefresh)
|
||||
{
|
||||
var cached = _cache.Get(itemId, TimeSpan.FromDays(Math.Max(1, config.CacheDays)), TimeSpan.FromHours(Math.Max(1, config.NotFoundCacheHours)));
|
||||
if (cached is not null)
|
||||
{
|
||||
return FromCache(cached, itemId, mediaLabel);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_cache.Invalidate(itemId);
|
||||
}
|
||||
|
||||
if (!RedditClient.IsConfigured)
|
||||
{
|
||||
return new CommentsResponse
|
||||
{
|
||||
Found = false,
|
||||
ItemId = itemId,
|
||||
MediaLabel = mediaLabel,
|
||||
Message = "Reddit API credentials are not configured. Ask your server admin to open Dashboard → Plugins → Reddit Comments."
|
||||
};
|
||||
}
|
||||
|
||||
var subreddits = (config.Subreddits ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(s => s.Length > 0)
|
||||
.ToList();
|
||||
if (subreddits.Count == 0)
|
||||
{
|
||||
subreddits.Add("anime");
|
||||
}
|
||||
|
||||
var queries = BuildQueries(item);
|
||||
var candidates = new List<(ThreadCandidate Candidate, int SubredditIndex)>();
|
||||
|
||||
foreach (var (subreddit, subIndex) in subreddits.Select((s, i) => (s, i)))
|
||||
{
|
||||
foreach (var query in queries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var path = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"/r/{0}/search.json?q={1}&restrict_sr=1&sort=relevance&t=all&type=link&limit=10&raw_json=1",
|
||||
Uri.EscapeDataString(subreddit),
|
||||
Uri.EscapeDataString(query));
|
||||
using var doc = await _redditClient.GetJsonAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var candidate in ParseSearchCandidates(doc))
|
||||
{
|
||||
if (!candidates.Any(c => c.Candidate.Id == candidate.Id))
|
||||
{
|
||||
candidates.Add((candidate, subIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Reddit search failed for query \"{Query}\" in r/{Subreddit}", query, subreddit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var best = PickBestCandidate(item, candidates);
|
||||
if (best is null)
|
||||
{
|
||||
_logger.LogInformation("No Reddit thread found for {Label} (searched {Count} candidates)", mediaLabel, candidates.Count);
|
||||
var notFound = new CachedThread { ItemId = itemId };
|
||||
_cache.Set(notFound);
|
||||
return FromCache(notFound, itemId, mediaLabel, cached: false);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found Reddit thread for {Label}: {Title} (r/{Subreddit}, {Comments} comments)", mediaLabel, best.Title, best.Subreddit, best.NumComments);
|
||||
|
||||
var commentsPath = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"/comments/{0}.json?raw_json=1&limit={1}&depth={2}&sort=top",
|
||||
best.Id,
|
||||
Math.Clamp(config.MaxComments, 1, 500),
|
||||
Math.Clamp(config.CommentDepth, 1, 10));
|
||||
using var commentsDoc = await _redditClient.GetJsonAsync(commentsPath, cancellationToken).ConfigureAwait(false);
|
||||
var comments = ParseCommentTree(commentsDoc, config.MinScore);
|
||||
|
||||
var entry = new CachedThread
|
||||
{
|
||||
ItemId = itemId,
|
||||
ThreadId = best.Id,
|
||||
ThreadTitle = best.Title,
|
||||
Subreddit = best.Subreddit,
|
||||
Permalink = best.Permalink,
|
||||
ThreadScore = best.Score,
|
||||
NumComments = best.NumComments,
|
||||
CommentsJson = JsonSerializer.Serialize(comments)
|
||||
};
|
||||
_cache.Set(entry);
|
||||
|
||||
return FromCache(entry, itemId, mediaLabel, cached: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the children of a subreddit search listing.
|
||||
/// </summary>
|
||||
/// <param name="searchDoc">The parsed search response.</param>
|
||||
/// <returns>The list of thread candidates.</returns>
|
||||
public static List<ThreadCandidate> ParseSearchCandidates(JsonDocument searchDoc)
|
||||
{
|
||||
var result = new List<ThreadCandidate>();
|
||||
if (!searchDoc.RootElement.TryGetProperty("data", out var data) || !data.TryGetProperty("children", out var children))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var child in children.EnumerateArray())
|
||||
{
|
||||
if (!child.TryGetProperty("data", out var post))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new ThreadCandidate
|
||||
{
|
||||
Id = GetString(post, "id"),
|
||||
Title = GetString(post, "title"),
|
||||
Subreddit = GetString(post, "subreddit"),
|
||||
Permalink = GetString(post, "permalink"),
|
||||
Score = GetInt(post, "score"),
|
||||
NumComments = GetInt(post, "num_comments"),
|
||||
LinkFlairText = GetString(post, "link_flair_text")
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a /comments/{id} response (array of [post listing, comments listing]) into a comment tree.
|
||||
/// </summary>
|
||||
/// <param name="commentsDoc">The parsed comments response.</param>
|
||||
/// <param name="minScore">Minimum score for a comment to be included.</param>
|
||||
/// <returns>The top-level comments.</returns>
|
||||
public static List<CommentDto> ParseCommentTree(JsonDocument commentsDoc, int minScore)
|
||||
{
|
||||
var result = new List<CommentDto>();
|
||||
if (commentsDoc.RootElement.ValueKind != JsonValueKind.Array || commentsDoc.RootElement.GetArrayLength() < 2)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var listing = commentsDoc.RootElement[1];
|
||||
if (!listing.TryGetProperty("data", out var data) || !data.TryGetProperty("children", out var children))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var child in children.EnumerateArray())
|
||||
{
|
||||
var comment = ParseComment(child, minScore);
|
||||
if (comment is not null)
|
||||
{
|
||||
result.Add(comment);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static CommentDto? ParseComment(JsonElement child, int minScore)
|
||||
{
|
||||
if (!child.TryGetProperty("kind", out var kind) || kind.GetString() != "t1")
|
||||
{
|
||||
return null; // skip "more" and anything else
|
||||
}
|
||||
|
||||
var data = child.GetProperty("data");
|
||||
var body = GetString(data, "body");
|
||||
if (string.IsNullOrEmpty(body) || body == "[deleted]" || body == "[removed]")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var score = GetInt(data, "score");
|
||||
if (score < minScore)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var comment = new CommentDto
|
||||
{
|
||||
Author = GetString(data, "author"),
|
||||
Body = body,
|
||||
Score = score,
|
||||
CreatedUtc = GetLong(data, "created_utc")
|
||||
};
|
||||
|
||||
if (data.TryGetProperty("replies", out var replies) && replies.ValueKind == JsonValueKind.Object
|
||||
&& replies.TryGetProperty("data", out var repliesData) && repliesData.TryGetProperty("children", out var replyChildren))
|
||||
{
|
||||
foreach (var replyChild in replyChildren.EnumerateArray())
|
||||
{
|
||||
var reply = ParseComment(replyChild, minScore);
|
||||
if (reply is not null)
|
||||
{
|
||||
comment.Replies.Add(reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
private static string BuildMediaLabel(BaseItem item)
|
||||
{
|
||||
if (item is Episode episode)
|
||||
{
|
||||
var series = GetSeriesName(episode) ?? episode.Name;
|
||||
var ep = episode.IndexNumber ?? 0;
|
||||
var season = episode.ParentIndexNumber ?? 1;
|
||||
return season > 1
|
||||
? string.Format(CultureInfo.InvariantCulture, "{0} — Season {1} Episode {2}", series, season, ep)
|
||||
: string.Format(CultureInfo.InvariantCulture, "{0} — Episode {1}", series, ep);
|
||||
}
|
||||
|
||||
if (item is Movie movie)
|
||||
{
|
||||
return movie.ProductionYear.HasValue
|
||||
? string.Format(CultureInfo.InvariantCulture, "{0} ({1})", movie.Name, movie.ProductionYear.Value)
|
||||
: movie.Name;
|
||||
}
|
||||
|
||||
return item.Name;
|
||||
}
|
||||
|
||||
private static string? GetSeriesName(Episode episode)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(episode.SeriesName))
|
||||
{
|
||||
return episode.SeriesName;
|
||||
}
|
||||
|
||||
return episode.FindParent<Series>()?.Name;
|
||||
}
|
||||
|
||||
private static List<string> BuildQueries(BaseItem item)
|
||||
{
|
||||
var queries = new List<string>();
|
||||
if (item is Episode episode)
|
||||
{
|
||||
var series = GetSeriesName(episode) ?? episode.Name;
|
||||
var ep = episode.IndexNumber ?? 0;
|
||||
var season = episode.ParentIndexNumber ?? 1;
|
||||
|
||||
queries.Add(string.Format(CultureInfo.InvariantCulture, "\"{0}\" episode {1}", series, ep));
|
||||
if (season > 1)
|
||||
{
|
||||
queries.Add(string.Format(CultureInfo.InvariantCulture, "\"{0}\" season {1} episode {2}", series, season, ep));
|
||||
}
|
||||
|
||||
var original = episode.FindParent<Series>()?.OriginalTitle;
|
||||
if (!string.IsNullOrWhiteSpace(original) && !original.Equals(series, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
queries.Add(string.Format(CultureInfo.InvariantCulture, "\"{0}\" episode {1}", original, ep));
|
||||
}
|
||||
}
|
||||
else if (item is Movie movie)
|
||||
{
|
||||
queries.Add(string.Format(CultureInfo.InvariantCulture, "\"{0}\" discussion", movie.Name));
|
||||
if (!string.IsNullOrWhiteSpace(movie.OriginalTitle) && !movie.OriginalTitle.Equals(movie.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
queries.Add(string.Format(CultureInfo.InvariantCulture, "\"{0}\" discussion", movie.OriginalTitle));
|
||||
}
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
private static ThreadCandidate? PickBestCandidate(BaseItem item, List<(ThreadCandidate Candidate, int SubredditIndex)> candidates)
|
||||
{
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item is Episode episode)
|
||||
{
|
||||
var series = GetSeriesName(episode) ?? episode.Name;
|
||||
var ep = episode.IndexNumber ?? 0;
|
||||
var titleTokens = Tokenize(series);
|
||||
var episodeRegex = new Regex(@"\b(?:episode|ep\.?)\s*#?\s*0?" + Regex.Escape(ep.ToString(CultureInfo.InvariantCulture)) + @"\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
var scored = candidates
|
||||
.Select(c =>
|
||||
{
|
||||
var episodeMatch = episodeRegex.IsMatch(c.Candidate.Title);
|
||||
var overlap = Tokenize(c.Candidate.Title).Count(titleTokens.Contains);
|
||||
var score = (episodeMatch ? 100 : 0)
|
||||
+ (overlap * 10)
|
||||
+ (c.Candidate.LinkFlairText.Contains("episode", StringComparison.OrdinalIgnoreCase) ? 5 : 0)
|
||||
+ Math.Min(c.Candidate.NumComments / 100, 9)
|
||||
- c.SubredditIndex;
|
||||
return (c.Candidate, EpisodeMatch: episodeMatch, Overlap: overlap, Score: score);
|
||||
})
|
||||
.OrderByDescending(c => c.Score)
|
||||
.ToList();
|
||||
|
||||
// Require the episode number to match, plus at least one title token in common.
|
||||
var best = scored.FirstOrDefault(c => c.EpisodeMatch && c.Overlap >= 1);
|
||||
if (best.Candidate is not null)
|
||||
{
|
||||
return best.Candidate;
|
||||
}
|
||||
|
||||
// Fallback: if exactly one candidate matches the episode number, trust the search relevance.
|
||||
var episodeMatches = scored.Where(c => c.EpisodeMatch).ToList();
|
||||
if (episodeMatches.Count == 1)
|
||||
{
|
||||
return episodeMatches[0].Candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item is Movie movie)
|
||||
{
|
||||
var titleTokens = Tokenize(movie.Name);
|
||||
var scored = candidates
|
||||
.Select(c =>
|
||||
{
|
||||
var overlap = Tokenize(c.Candidate.Title).Count(titleTokens.Contains);
|
||||
var discussion = c.Candidate.Title.Contains("discussion", StringComparison.OrdinalIgnoreCase);
|
||||
var score = (overlap * 10)
|
||||
+ (discussion ? 20 : 0)
|
||||
+ (c.Candidate.Title.Contains("movie", StringComparison.OrdinalIgnoreCase) ? 5 : 0)
|
||||
+ Math.Min(c.Candidate.NumComments / 100, 9)
|
||||
- c.SubredditIndex;
|
||||
return (c.Candidate, Overlap: overlap, Discussion: discussion, Score: score);
|
||||
})
|
||||
.OrderByDescending(c => c.Score)
|
||||
.ToList();
|
||||
|
||||
var best = scored.FirstOrDefault(c => c.Discussion && c.Overlap >= 1);
|
||||
return best.Candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static HashSet<string> Tokenize(string text)
|
||||
{
|
||||
var tokens = Regex.Split(text.ToLowerInvariant(), @"[^a-z0-9]+")
|
||||
.Where(t => t.Length >= 2 && !Stopwords.Contains(t));
|
||||
return new HashSet<string>(tokens, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private CommentsResponse FromCache(CachedThread entry, string itemId, string mediaLabel, bool cached = true)
|
||||
{
|
||||
var found = entry.ThreadId.Length > 0;
|
||||
return new CommentsResponse
|
||||
{
|
||||
Found = found,
|
||||
ItemId = itemId,
|
||||
MediaLabel = mediaLabel,
|
||||
ThreadId = entry.ThreadId,
|
||||
ThreadTitle = entry.ThreadTitle,
|
||||
Subreddit = entry.Subreddit,
|
||||
Permalink = entry.Permalink,
|
||||
ThreadScore = entry.ThreadScore,
|
||||
NumComments = entry.NumComments,
|
||||
FetchedAt = entry.FetchedAt == default ? DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) : entry.FetchedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
Cached = cached,
|
||||
Message = found ? string.Empty : "No Reddit discussion thread was found for this title.",
|
||||
Comments = found
|
||||
? JsonSerializer.Deserialize<List<CommentDto>>(entry.CommentsJson) ?? new List<CommentDto>()
|
||||
: new List<CommentDto>()
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetString(JsonElement element, string property)
|
||||
=> element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
private static int GetInt(JsonElement element, string property)
|
||||
=> element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var result) ? result : 0;
|
||||
|
||||
private static long GetLong(JsonElement element, string property)
|
||||
=> element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var result) ? result : 0;
|
||||
}
|
||||
106
Jellyfin.Plugin.RedditComments/Services/WebScriptInjector.cs
Normal file
106
Jellyfin.Plugin.RedditComments/Services/WebScriptInjector.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.RedditComments.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Injects the plugin's script tag into the Jellyfin web client's index.html at startup,
|
||||
/// so the player gets the comments button. Re-applies automatically after web client updates.
|
||||
/// </summary>
|
||||
public class WebScriptInjector : IHostedService
|
||||
{
|
||||
private const string ScriptMarker = "RedditComments/Static/reddit-comments.js";
|
||||
|
||||
private readonly ILogger<WebScriptInjector> _logger;
|
||||
private readonly IApplicationPaths _applicationPaths;
|
||||
private readonly IServerConfigurationManager _configurationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="WebScriptInjector"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="applicationPaths">Application paths.</param>
|
||||
/// <param name="configurationManager">Server configuration manager.</param>
|
||||
public WebScriptInjector(
|
||||
ILogger<WebScriptInjector> logger,
|
||||
IApplicationPaths applicationPaths,
|
||||
IServerConfigurationManager configurationManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_applicationPaths = applicationPaths;
|
||||
_configurationManager = configurationManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
InjectScriptTag();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Could not inject the Reddit Comments script into the web client. The API will still work; "
|
||||
+ "to enable the player button, add this tag to index.html of jellyfin-web manually: {Tag}",
|
||||
BuildScriptTag());
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private string BuildScriptTag()
|
||||
{
|
||||
var baseUrl = string.Empty;
|
||||
try
|
||||
{
|
||||
if (_configurationManager.GetConfiguration("network") is NetworkConfiguration networkConfiguration)
|
||||
{
|
||||
baseUrl = (networkConfiguration.BaseUrl ?? string.Empty).TrimEnd('/');
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Could not read the configured base URL, assuming none");
|
||||
}
|
||||
|
||||
return string.Format(System.Globalization.CultureInfo.InvariantCulture, "<script src=\"{0}/{1}\" defer></script>", baseUrl, ScriptMarker);
|
||||
}
|
||||
|
||||
private void InjectScriptTag()
|
||||
{
|
||||
var indexPath = Path.Combine(_applicationPaths.WebPath, "index.html");
|
||||
if (!File.Exists(indexPath))
|
||||
{
|
||||
_logger.LogWarning("jellyfin-web index.html not found at {Path}, skipping script injection", indexPath);
|
||||
return;
|
||||
}
|
||||
|
||||
var contents = File.ReadAllText(indexPath);
|
||||
if (contents.Contains(ScriptMarker, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogDebug("Reddit Comments script tag already present in index.html");
|
||||
return;
|
||||
}
|
||||
|
||||
var tag = BuildScriptTag();
|
||||
var bodyIndex = contents.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase);
|
||||
contents = bodyIndex >= 0
|
||||
? contents.Insert(bodyIndex, tag + Environment.NewLine)
|
||||
: contents + Environment.NewLine + tag + Environment.NewLine;
|
||||
|
||||
File.WriteAllText(indexPath, contents);
|
||||
_logger.LogInformation("Injected Reddit Comments script tag into {Path}", indexPath);
|
||||
}
|
||||
}
|
||||
481
Jellyfin.Plugin.RedditComments/Web/reddit-comments.js
Normal file
481
Jellyfin.Plugin.RedditComments/Web/reddit-comments.js
Normal file
@@ -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();
|
||||
})();
|
||||
121
README.md
Normal file
121
README.md
Normal file
@@ -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 <https://www.reddit.com/prefs/apps> 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: `<your config volume>/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
|
||||
`"<series name>" episode <N>` (plus season/original-title variants) and scores candidates by
|
||||
episode-number match and title similarity. Results are cached per Jellyfin item.
|
||||
- **Caching**: SQLite at `<config>/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 `<script>` tag for its player script to
|
||||
jellyfin-web's `index.html` (re-applied automatically after web client updates). If your web
|
||||
directory is read-only (some Docker setups), the plugin logs a warning with the tag to add
|
||||
manually; the API still works regardless.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Episode matching assumes the Reddit thread uses the same episode number as your library
|
||||
(r/anime uses the show's broadcast numbering). If your library uses absolute numbering but the
|
||||
thread uses per-season numbering (or vice versa), the thread may not be found.
|
||||
- The button/sidebar only exists in the Jellyfin **web** player. Other clients (Android TV,
|
||||
Swiftfin, etc.) are not supported.
|
||||
- Only episodes and movies are supported.
|
||||
|
||||
## Building from source
|
||||
|
||||
Requires the .NET 9 SDK:
|
||||
|
||||
```bash
|
||||
dotnet publish Jellyfin.Plugin.RedditComments/Jellyfin.Plugin.RedditComments.csproj -c Release -o publish
|
||||
```
|
||||
|
||||
Copy the plugin DLL plus `Microsoft.Data.Sqlite.dll`, the `SQLitePCLRaw.*.dll`s and the native
|
||||
`e_sqlite3` library for your platform (under `publish/runtimes/<rid>/native/`) into your plugins
|
||||
folder. The `dist/` zip in this repo is already assembled this way.
|
||||
|
||||
Run the smoke tests (cache, Reddit response parsing, rate limiter):
|
||||
|
||||
```bash
|
||||
dotnet run --project SmokeTests/SmokeTests.csproj -c Release
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
1. Remove the `RedditComments` folder from the plugins directory and restart.
|
||||
2. Optionally delete the cache database `<config>/plugins/RedditComments/reddit-comments.db`.
|
||||
3. The injected `<script src=".../RedditComments/Static/reddit-comments.js">` tag in jellyfin-web's
|
||||
`index.html` becomes a harmless 404 after uninstall; remove it manually if you want it gone
|
||||
(it is also overwritten on every jellyfin-web update).
|
||||
124
SmokeTests/Program.cs
Normal file
124
SmokeTests/Program.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using Jellyfin.Plugin.RedditComments.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
var failures = 0;
|
||||
|
||||
void Check(bool condition, string name)
|
||||
{
|
||||
Console.WriteLine((condition ? "PASS" : "FAIL") + " " + name);
|
||||
if (!condition)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CommentCache
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), "rc-test-" + Guid.NewGuid().ToString("N"), "cache.db");
|
||||
var cache = new CommentCache(dbPath, NullLogger<CommentCache>.Instance);
|
||||
|
||||
Check(cache.Get("item1", TimeSpan.FromDays(30), TimeSpan.FromHours(24)) is null, "cache: miss on empty db");
|
||||
|
||||
cache.Set(new CachedThread
|
||||
{
|
||||
ItemId = "item1",
|
||||
ThreadId = "abc123",
|
||||
ThreadTitle = "Some Show - Episode 5 discussion",
|
||||
Subreddit = "anime",
|
||||
Permalink = "/r/anime/comments/abc123/x/",
|
||||
ThreadScore = 500,
|
||||
NumComments = 321,
|
||||
CommentsJson = "[{\"Author\":\"u\",\"Body\":\"b\",\"Score\":1,\"CreatedUtc\":1,\"Replies\":[]}]"
|
||||
});
|
||||
|
||||
var hit = cache.Get("item1", TimeSpan.FromDays(30), TimeSpan.FromHours(24));
|
||||
Check(hit is not null && hit.ThreadId == "abc123" && hit.NumComments == 321, "cache: hit after set");
|
||||
Check(hit!.FetchedAt > DateTime.UtcNow.AddMinutes(-1), "cache: fetched_at stamped");
|
||||
|
||||
var stale = cache.Get("item1", TimeSpan.Zero, TimeSpan.FromHours(24));
|
||||
Check(stale is null, "cache: stale entry treated as miss");
|
||||
|
||||
// negative caching
|
||||
cache.Set(new CachedThread { ItemId = "item2" });
|
||||
var notFound = cache.Get("item2", TimeSpan.FromDays(30), TimeSpan.FromHours(24));
|
||||
Check(notFound is not null && notFound.ThreadId == string.Empty, "cache: negative result cached");
|
||||
Check(cache.Get("item2", TimeSpan.FromDays(30), TimeSpan.Zero) is null, "cache: negative result expires on its own ttl");
|
||||
|
||||
cache.Invalidate("item1");
|
||||
Check(cache.Get("item1", TimeSpan.FromDays(30), TimeSpan.FromHours(24)) is null, "cache: invalidate removes entry");
|
||||
|
||||
// reopen against the same file to prove persistence
|
||||
var cache2 = new CommentCache(dbPath, NullLogger<CommentCache>.Instance);
|
||||
Check(cache2.Get("item2", TimeSpan.FromDays(30), TimeSpan.FromHours(24)) is not null, "cache: persists across instances");
|
||||
|
||||
// ---------------------------------------------------------------- Parsing: search
|
||||
const string searchJson = """
|
||||
{"kind":"Listing","data":{"children":[
|
||||
{"kind":"t3","data":{"id":"abc123","title":"Some Show - Episode 5 discussion","subreddit":"anime","permalink":"/r/anime/comments/abc123/x/","score":500,"num_comments":321,"link_flair_text":"Episode Discussion"}},
|
||||
{"kind":"t3","data":{"id":"def456","title":"Unrelated post","subreddit":"anime","permalink":"/r/anime/comments/def456/y/","score":10,"num_comments":2,"link_flair_text":null}}
|
||||
]}}
|
||||
""";
|
||||
using (var doc = JsonDocument.Parse(searchJson))
|
||||
{
|
||||
var candidates = RedditCommentsService.ParseSearchCandidates(doc);
|
||||
Check(candidates.Count == 2, "search: parses all candidates");
|
||||
Check(candidates[0].Id == "abc123" && candidates[0].NumComments == 321, "search: fields mapped");
|
||||
Check(candidates[1].LinkFlairText == string.Empty, "search: null flair becomes empty");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Parsing: comments
|
||||
const string commentsJson = """
|
||||
[
|
||||
{"kind":"Listing","data":{"children":[{"kind":"t3","data":{"id":"abc123","title":"Thread"}}]}},
|
||||
{"kind":"Listing","data":{"children":[
|
||||
{"kind":"t1","data":{"author":"user1","body":"Great episode","score":42,"created_utc":1752000000,"replies":{"kind":"Listing","data":{"children":[
|
||||
{"kind":"t1","data":{"author":"user2","body":"Agreed","score":10,"created_utc":1752000100,"replies":""}},
|
||||
{"kind":"more","data":{"children":["x","y"]}}
|
||||
]}}}},
|
||||
{"kind":"t1","data":{"author":"user3","body":"[deleted]","score":50,"created_utc":1752000200,"replies":""}},
|
||||
{"kind":"t1","data":{"author":"user4","body":"downvoted take","score":-3,"created_utc":1752000300,"replies":""}},
|
||||
{"kind":"more","data":{"children":["z"]}}
|
||||
]}}
|
||||
]
|
||||
""";
|
||||
using (var doc = JsonDocument.Parse(commentsJson))
|
||||
{
|
||||
var comments = RedditCommentsService.ParseCommentTree(doc, minScore: 1);
|
||||
Check(comments.Count == 1, "comments: deleted/downvoted/more filtered out");
|
||||
Check(comments[0].Author == "user1" && comments[0].Score == 42, "comments: fields mapped");
|
||||
Check(comments[0].Replies.Count == 1 && comments[0].Replies[0].Author == "user2", "comments: nested replies parsed, 'more' skipped");
|
||||
|
||||
var everything = RedditCommentsService.ParseCommentTree(doc, minScore: -100);
|
||||
Check(everything.Count == 2, "comments: low minScore keeps downvoted but not deleted");
|
||||
}
|
||||
|
||||
// round-trip through the cache serialization format
|
||||
using (var doc = JsonDocument.Parse(commentsJson))
|
||||
{
|
||||
var comments = RedditCommentsService.ParseCommentTree(doc, minScore: 1);
|
||||
var serialized = JsonSerializer.Serialize(comments);
|
||||
var deserialized = JsonSerializer.Deserialize<List<Jellyfin.Plugin.RedditComments.Models.CommentDto>>(serialized);
|
||||
Check(deserialized is not null && deserialized.Count == 1 && deserialized[0].Replies.Count == 1, "comments: JSON round-trip");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RateLimiter
|
||||
var limiter = new RateLimiter(maxPerMinuteOverride: 2);
|
||||
await limiter.WaitAsync();
|
||||
await limiter.WaitAsync();
|
||||
|
||||
var third = limiter.WaitAsync();
|
||||
var completed = await Task.WhenAny(third, Task.Delay(TimeSpan.FromMilliseconds(500))) == third;
|
||||
Check(!completed, "ratelimit: 3rd request within the window is blocked");
|
||||
|
||||
var limiter2 = new RateLimiter(maxPerMinuteOverride: 60);
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
await limiter2.WaitAsync();
|
||||
}
|
||||
sw.Stop();
|
||||
Check(sw.ElapsedMilliseconds < 1000, "ratelimit: 60 requests pass instantly under the limit");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(failures == 0 ? "ALL TESTS PASSED" : failures + " TEST(S) FAILED");
|
||||
return failures == 0 ? 0 : 1;
|
||||
14
SmokeTests/SmokeTests.csproj
Normal file
14
SmokeTests/SmokeTests.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Jellyfin.Plugin.RedditComments\Jellyfin.Plugin.RedditComments.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
20
manifest.json
Normal file
20
manifest.json
Normal file
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"guid": "7b2f1c4e-9a3d-4f6b-8c5e-2d1a0f9e7b6c",
|
||||
"name": "Reddit Comments",
|
||||
"description": "Adds a comments button to the video player that opens a sidebar with the Reddit discussion thread for the episode or movie you are watching (e.g. r/anime episode threads). Comments are cached in a local SQLite database and Reddit is only contacted when you click the button.",
|
||||
"overview": "Shows Reddit discussion threads inside the Jellyfin web player. A new button in the player OSD opens a sidebar with the episode's Reddit thread. Threads are found by searching your configured subreddits, comments are cached locally in SQLite to minimize API usage, and requests to Reddit are rate limited (60/minute by default). Nothing is fetched until you click the button.",
|
||||
"owner": "you",
|
||||
"category": "General",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0.0",
|
||||
"changelog": "- Initial release\n- Player OSD button + comments sidebar\n- Reddit thread search across configurable subreddits\n- SQLite caching with separate TTLs for found/not-found results\n- Sliding-window rate limiter (default 60 requests/minute, max 100)\n- Lazy loading: Reddit is only contacted on button click",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://YOUR-HOSTING-URL/Jellyfin.Plugin.RedditComments_1.0.0.0.zip",
|
||||
"checksum": "9ee28786f8f9c7edb17f3f7c147a6df9",
|
||||
"timestamp": "2026-07-16T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user