init
This commit is contained in:
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();
|
||||
})();
|
||||
Reference in New Issue
Block a user