Files
Jellyfin-Comments/Jellyfin.Plugin.RedditComments/Services/CommentCache.cs
Gabrieal Jimmy f321976d70 init
2026-07-16 13:14:58 -05:00

211 lines
7.6 KiB
C#

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