211 lines
7.6 KiB
C#
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;
|
|
}
|
|
}
|