using System; using System.Globalization; using System.IO; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.RedditComments.Services; /// /// One cached lookup for a Jellyfin item. /// public class CachedThread { /// Gets or sets the Jellyfin item id. public string ItemId { get; set; } = string.Empty; /// Gets or sets the Reddit thread id (empty when nothing was found). public string ThreadId { get; set; } = string.Empty; /// Gets or sets the Reddit thread title. public string ThreadTitle { get; set; } = string.Empty; /// Gets or sets the subreddit the thread was found in. public string Subreddit { get; set; } = string.Empty; /// Gets or sets the thread permalink path. public string Permalink { get; set; } = string.Empty; /// Gets or sets the thread score. public int ThreadScore { get; set; } /// Gets or sets the thread's total comment count. public int NumComments { get; set; } /// Gets or sets when this entry was fetched (UTC). public DateTime FetchedAt { get; set; } /// Gets or sets the serialized comments JSON array. public string CommentsJson { get; set; } = "[]"; } /// /// SQLite-backed cache of Reddit threads/comments for Jellyfin items. /// Avoids hitting the Reddit API again for episodes that were already looked up. /// public class CommentCache { private readonly string _dbPath; private readonly ILogger _logger; private readonly object _lock = new object(); private bool _initialized; /// /// Initializes a new instance of the class. /// /// Full path of the SQLite database file. /// Logger. public CommentCache(string dbPath, ILogger logger) { _dbPath = dbPath; _logger = logger; } /// /// Gets a cached entry if it exists and is still fresh. /// /// Jellyfin item id. /// Freshness window for successful lookups. /// Freshness window for negative lookups. /// The cached entry, or null if missing/stale. public CachedThread? Get(string itemId, TimeSpan foundTtl, TimeSpan notFoundTtl) { lock (_lock) { EnsureInitialized(); using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = "SELECT thread_id, thread_title, subreddit, permalink, thread_score, num_comments, fetched_at, comments_json FROM threads WHERE item_id = $itemId"; command.Parameters.AddWithValue("$itemId", itemId); using var reader = command.ExecuteReader(); if (!reader.Read()) { return null; } var entry = new CachedThread { ItemId = itemId, ThreadId = reader.GetString(0), ThreadTitle = reader.GetString(1), Subreddit = reader.GetString(2), Permalink = reader.GetString(3), ThreadScore = reader.GetInt32(4), NumComments = reader.GetInt32(5), FetchedAt = DateTime.Parse(reader.GetString(6), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), CommentsJson = reader.GetString(7) }; var found = entry.ThreadId.Length > 0; var ttl = found ? foundTtl : notFoundTtl; if (DateTime.UtcNow - entry.FetchedAt > ttl) { return null; } return entry; } } /// /// Stores or replaces the entry for an item. Stamps with the current time. /// /// The entry to store. public void Set(CachedThread entry) { lock (_lock) { EnsureInitialized(); entry.FetchedAt = DateTime.UtcNow; using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = @" INSERT INTO threads (item_id, thread_id, thread_title, subreddit, permalink, thread_score, num_comments, fetched_at, comments_json) VALUES ($itemId, $threadId, $threadTitle, $subreddit, $permalink, $threadScore, $numComments, $fetchedAt, $commentsJson) ON CONFLICT(item_id) DO UPDATE SET thread_id = $threadId, thread_title = $threadTitle, subreddit = $subreddit, permalink = $permalink, thread_score = $threadScore, num_comments = $numComments, fetched_at = $fetchedAt, comments_json = $commentsJson"; command.Parameters.AddWithValue("$itemId", entry.ItemId); command.Parameters.AddWithValue("$threadId", entry.ThreadId); command.Parameters.AddWithValue("$threadTitle", entry.ThreadTitle); command.Parameters.AddWithValue("$subreddit", entry.Subreddit); command.Parameters.AddWithValue("$permalink", entry.Permalink); command.Parameters.AddWithValue("$threadScore", entry.ThreadScore); command.Parameters.AddWithValue("$numComments", entry.NumComments); command.Parameters.AddWithValue("$fetchedAt", entry.FetchedAt.ToString("O", CultureInfo.InvariantCulture)); command.Parameters.AddWithValue("$commentsJson", entry.CommentsJson); command.ExecuteNonQuery(); } } /// /// Deletes the cached entry for an item (used by manual refresh). /// /// Jellyfin item id. public void Invalidate(string itemId) { lock (_lock) { EnsureInitialized(); using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = "DELETE FROM threads WHERE item_id = $itemId"; command.Parameters.AddWithValue("$itemId", itemId); command.ExecuteNonQuery(); } } private void EnsureInitialized() { if (_initialized) { return; } var directory = Path.GetDirectoryName(_dbPath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = @" CREATE TABLE IF NOT EXISTS threads ( item_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, thread_title TEXT NOT NULL, subreddit TEXT NOT NULL, permalink TEXT NOT NULL, thread_score INTEGER NOT NULL, num_comments INTEGER NOT NULL, fetched_at TEXT NOT NULL, comments_json TEXT NOT NULL )"; command.ExecuteNonQuery(); _initialized = true; _logger.LogInformation("Reddit comments cache initialized at {Path}", _dbPath); } private SqliteConnection OpenConnection() { var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadWriteCreate"); connection.Open(); using var pragma = connection.CreateCommand(); pragma.CommandText = "PRAGMA journal_mode=WAL"; pragma.ExecuteNonQuery(); return connection; } }