using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Data.SQLite; using RestSharp.Authenticators.OAuth; using RestSharp.Authenticators; using RestSharp; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Microsoft.Extensions.Diagnostics.Latency; using System.Collections; using Microsoft.Extensions.Configuration; using System.IO; using System.Data; using System.Text.Json; namespace URLNotesGrabberCORE { class ReblogRecord { public string answer; public string audioCaption; public string blogName; public string body; public string date; public string downloadedFiles; public string link; public string photoCaption; public string photoURL; public string postID; public string postURL; public string question; public string quote; public string reblogKey; public string reblogName; public string reblogURL; public string rootURL; public string slug; public string summary; public string tags; public string title; public ReblogRecord() { answer = "."; audioCaption = "."; blogName = "."; body = "."; date = "."; downloadedFiles = "."; link = "."; photoCaption = "."; photoURL = "."; postID = "."; postURL = "."; question = "."; quote = "."; reblogKey = "."; reblogName = "."; reblogURL = "."; rootURL = "."; slug = "."; summary = "."; tags = "."; title = "."; } } internal class DataAccess { private static IConfiguration? _configuration; private static HashSet? _postIdsToExclude; private static readonly object _importPragmaLock = new object(); private static Tuple? _savedImportPragmas; private static string? _cachedDbPath; private static SQLiteConnection? _importConnection; private static HashSet? _importBlogCache; private static readonly object _importSessionLock = new object(); static DataAccess() { _configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .Build(); var excludeSetting = _configuration["appSettings:PostIDToExclude"]; if (!string.IsNullOrWhiteSpace(excludeSetting)) { _postIdsToExclude = excludeSetting.Split(',') .Select(s => long.TryParse(s.Trim(), out var id) ? id : (long?)null) .Where(id => id.HasValue) .Select(id => id.Value) .ToHashSet(); } else { _postIdsToExclude = new HashSet(); } } private static string GetDefaultDbPath() { if (_cachedDbPath != null) return _cachedDbPath; string? configPath = _configuration?["appSettings:PathDB"]; if (!string.IsNullOrWhiteSpace(configPath)) { _cachedDbPath = configPath; } else { // Fallback to 3 levels up from the executable directory _cachedDbPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "TL.db"); } return _cachedDbPath; } public static string Q(string input) { return "'" + input.Replace("'", "''") + "'"; } public static DateTime UnixTimeStampToDateTime(double unixTimeStamp) { // Unix timestamp is seconds past epoch DateTime dateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); dateTime = dateTime.AddSeconds(unixTimeStamp).ToLocalTime(); return dateTime; } public static void EnableImportModePragmas(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); lock (_importPragmaLock) { if (_savedImportPragmas != null) return; using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); string currentJournalMode = "delete"; string currentSynchronous = "2"; using (var cmd = new SQLiteCommand("PRAGMA journal_mode;", connection)) { currentJournalMode = Convert.ToString(cmd.ExecuteScalar()) ?? "delete"; } using (var cmd = new SQLiteCommand("PRAGMA synchronous;", connection)) { currentSynchronous = Convert.ToString(cmd.ExecuteScalar()) ?? "2"; } _savedImportPragmas = new Tuple(currentJournalMode, currentSynchronous); using (var cmd = new SQLiteCommand("PRAGMA journal_mode=WAL;", connection)) { cmd.ExecuteNonQuery(); } using (var cmd = new SQLiteCommand("PRAGMA synchronous=NORMAL;", connection)) { cmd.ExecuteNonQuery(); } Console.WriteLine($"[SQLite Import Mode] Enabled | journal_mode=WAL | synchronous=NORMAL (previous: journal_mode={currentJournalMode}, synchronous={currentSynchronous})"); } catch (Exception ex) { Console.WriteLine($"[SQLite Import Mode] Failed to enable import pragmas: {ex.Message}"); } } } public static void RestoreImportModePragmas(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); lock (_importPragmaLock) { if (_savedImportPragmas == null) return; using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); string priorJournalMode = _savedImportPragmas.Item1; string priorSynchronous = _savedImportPragmas.Item2; using (var cmd = new SQLiteCommand($"PRAGMA journal_mode={priorJournalMode};", connection)) { cmd.ExecuteNonQuery(); } using (var cmd = new SQLiteCommand($"PRAGMA synchronous={priorSynchronous};", connection)) { cmd.ExecuteNonQuery(); } Console.WriteLine($"[SQLite Import Mode] Restored | journal_mode={priorJournalMode} | synchronous={priorSynchronous}"); } catch (Exception ex) { Console.WriteLine($"[SQLite Import Mode] Failed to restore pragmas: {ex.Message}"); } finally { _savedImportPragmas = null; } } } public static void BeginImportSession(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); lock (_importSessionLock) { if (_importConnection != null) return; var conn = new SQLiteConnection("Data Source=" + DBPath); conn.Open(); _importConnection = conn; _importBlogCache = new HashSet(StringComparer.OrdinalIgnoreCase); Console.WriteLine("[Import Session] Opened single connection | blog cache initialized (lazy)"); } } public static void EndImportSession() { lock (_importSessionLock) { try { _importConnection?.Close(); _importConnection?.Dispose(); } catch { } _importConnection = null; _importBlogCache = null; } } public static void EnsureReplyTextColumnExists(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); // Check if replyText column exists string checkSql = "PRAGMA table_info(Notes);"; using (SQLiteCommand command = new SQLiteCommand(checkSql, connection)) { using (SQLiteDataReader reader = command.ExecuteReader()) { bool columnExists = false; while (reader.Read()) { string columnName = reader.GetString(1); if (columnName.Equals("replyText", StringComparison.OrdinalIgnoreCase)) { columnExists = true; break; } } if (!columnExists) { // Add the column if it doesn't exist connection.Close(); connection.Open(); string addColumnSql = "ALTER TABLE Notes ADD COLUMN replyText TEXT DEFAULT '.';"; using (SQLiteCommand addCommand = new SQLiteCommand(addColumnSql, connection)) { addCommand.ExecuteNonQuery(); Console.WriteLine("[Migration] Added replyText column to Notes table"); } } } } } catch (Exception ex) { // Breakpoint here Console.WriteLine($"Error checking/creating replyText column: {ex.Message}"); } } public static void EnsureBlogsLikesColumnsExist(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); string checkSql = "PRAGMA table_info(Blogs);"; bool likesPulledExists = false; bool likesCursorExists = false; bool likesNewestTimestampExists = false; bool likesLastRefreshedExists = false; bool likesLastNewCountExists = false; using (SQLiteCommand command = new SQLiteCommand(checkSql, connection)) { using (SQLiteDataReader reader = command.ExecuteReader()) { while (reader.Read()) { string columnName = reader.GetString(1); if (columnName.Equals("LikesPulled", StringComparison.OrdinalIgnoreCase)) likesPulledExists = true; if (columnName.Equals("LikesCursor", StringComparison.OrdinalIgnoreCase)) likesCursorExists = true; if (columnName.Equals("LikesNewestTimestamp", StringComparison.OrdinalIgnoreCase)) likesNewestTimestampExists = true; if (columnName.Equals("LikesLastRefreshed", StringComparison.OrdinalIgnoreCase)) likesLastRefreshedExists = true; if (columnName.Equals("LikesLastNewCount", StringComparison.OrdinalIgnoreCase)) likesLastNewCountExists = true; } } } if (!likesPulledExists) { string addCol = "ALTER TABLE Blogs ADD COLUMN LikesPulled INTEGER DEFAULT 0;"; using (SQLiteCommand cmd = new SQLiteCommand(addCol, connection)) cmd.ExecuteNonQuery(); Console.WriteLine("[Migration] Added LikesPulled column to Blogs table"); } if (!likesCursorExists) { string addCol = "ALTER TABLE Blogs ADD COLUMN LikesCursor INTEGER DEFAULT 0;"; using (SQLiteCommand cmd = new SQLiteCommand(addCol, connection)) cmd.ExecuteNonQuery(); Console.WriteLine("[Migration] Added LikesCursor column to Blogs table"); } if (!likesNewestTimestampExists) { string addCol = "ALTER TABLE Blogs ADD COLUMN LikesNewestTimestamp INTEGER DEFAULT 0;"; using (SQLiteCommand cmd = new SQLiteCommand(addCol, connection)) cmd.ExecuteNonQuery(); Console.WriteLine("[Migration] Added LikesNewestTimestamp column to Blogs table"); } if (!likesLastRefreshedExists) { string addCol = "ALTER TABLE Blogs ADD COLUMN LikesLastRefreshed INTEGER DEFAULT 0;"; using (SQLiteCommand cmd = new SQLiteCommand(addCol, connection)) cmd.ExecuteNonQuery(); Console.WriteLine("[Migration] Added LikesLastRefreshed column to Blogs table"); } if (!likesLastNewCountExists) { string addCol = "ALTER TABLE Blogs ADD COLUMN LikesLastNewCount INTEGER DEFAULT 0;"; using (SQLiteCommand cmd = new SQLiteCommand(addCol, connection)) cmd.ExecuteNonQuery(); Console.WriteLine("[Migration] Added LikesLastNewCount column to Blogs table"); } // One-time reset: if any of the new high-water-mark columns were just added, // wipe all likes tracking state so the system starts from a known-good baseline. // Existing Posts rows with ByLikes=1 remain — UNIQUE(BlogName, PostID) absorbs re-inserts. if (!likesNewestTimestampExists || !likesLastRefreshedExists || !likesLastNewCountExists) { string resetSql = @"UPDATE Blogs SET LikesPulled = 0, LikesCursor = 0, LikesNewestTimestamp = 0, LikesLastRefreshed = 0, LikesLastNewCount = 0;"; using (SQLiteCommand cmd = new SQLiteCommand(resetSql, connection)) { int affected = cmd.ExecuteNonQuery(); Console.WriteLine($"[Migration] Reset likes tracking on {affected} blog rows for clean baseline"); } } } catch (Exception ex) { Console.WriteLine($"Error mapping Blogs likes columns: {ex.Message}"); } } #region Adds public static void AddBlog(string blogName, bool byLikes = false, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); if (blogName.Contains("deact")) return; SQLiteConnection connection; bool ownsConnection; lock (_importSessionLock) { if (_importConnection != null) { if (_importBlogCache != null && _importBlogCache.Contains(blogName)) return; connection = _importConnection; ownsConnection = false; } else { connection = new SQLiteConnection("Data Source=" + DBPath); connection.Open(); ownsConnection = true; } } try { // Insert BlogName and DateAdded (current UTC datetime) string sql = "INSERT OR IGNORE INTO Blogs (BlogName, DateAdded, DateModified, DateCreated, ByLikes) VALUES (@BlogName, @DateAdded, @DateModified, @DateCreated, @ByLikes)"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@BlogName", blogName); command.Parameters.AddWithValue("@DateAdded", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@DateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@DateCreated", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@ByLikes", byLikes ? 1 : 0); command.ExecuteNonQuery(); } _importBlogCache?.Add(blogName); // Console.WriteLine("+ " + blogName); } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Blogs.BlogName") Console.WriteLine(ex.Message); } finally { if (ownsConnection) connection.Close(); } } public static void AddPost(string blogName, long postID, string reblogURL, string postDate, string postURL, string slug, string reblogKey, string reblogName, string summary, string quote, string body, string tags, string link, string photoURL, string photoCaption, string downloadedFiles, string audioCaption, string question, string answer, string title, bool hasImage, bool byLikes = false, string? DBPath = null, string? rootBlogName = null, string? rootURL = null) { DBPath ??= GetDefaultDbPath(); try { AddBlog(blogName, byLikes, DBPath); } catch { } try { UpdatePostSetDate(blogName, postID, postDate, DBPath); } catch { } try { UpdatePost(blogName, postID, reblogURL, postDate, postURL, slug, reblogKey, reblogName, summary, quote, body, tags, link, photoURL, photoCaption, downloadedFiles, audioCaption, question, answer, title, hasImage, byLikes, DBPath, rootBlogName, rootURL); } catch { } SQLiteConnection connection; bool ownsConnection; lock (_importSessionLock) { if (_importConnection != null) { connection = _importConnection; ownsConnection = false; } else { connection = new SQLiteConnection("Data Source=" + DBPath); ownsConnection = true; } } try { if (ownsConnection) connection.Open(); string sql = @"INSERT INTO Posts ( BlogName, PostID, reblogURL, PostDate, PostURL, Slug, ReblogKey, ReblogName, Summary, Quote, Body, Tags, Link, PhotoURL, PhotoCaption, DownloadedFiles, AudioCaption, Question, Answer, Title, DateModified, DateCreated, RootBlogName, RootURL, HasImage, ByLikes ) VALUES (" + Q(blogName) + ", " + postID + ", " + Q(reblogURL) + ", " + Q(postDate) + ", " + Q(postURL) + ", " + Q(slug) + ", " + Q(reblogKey) + ", " + Q(reblogName) + ", " + Q(summary) + ", " + Q(quote) + ", " + Q(body) + ", " + Q(tags) + ", " + Q(link) + ", " + Q(photoURL) + ", " + Q(photoCaption) + ", " + Q(downloadedFiles) + ", " + Q(audioCaption) + ", " + Q(question) + ", " + Q(answer) + ", " + Q(title) + ", " + Q(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")) + ", " + Q(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")) + ", " + Q(rootBlogName ?? ".") + ", " + Q(rootURL ?? ".") + ", " + (hasImage ? 1 : 0) + ", " + (byLikes ? 1 : 0) + ")"; SQLiteCommand command = new SQLiteCommand(sql, connection); int rowsInserted = 0; try { rowsInserted = command.ExecuteNonQuery(); } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") { Console.WriteLine(ex.Message); /// try { if (ownsConnection) connection.Open(); string updateSql = "UPDATE Posts SET hasImage = @hasImage, DateModified = @DateModified WHERE blogName = @blogName AND postID = @postID"; using SQLiteCommand updateCommand = new SQLiteCommand(updateSql, connection); updateCommand.Parameters.AddWithValue("@hasImage", hasImage ? 1 : 0); updateCommand.Parameters.AddWithValue("@DateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); updateCommand.Parameters.AddWithValue("@blogName", blogName); updateCommand.Parameters.AddWithValue("@postID", postID); updateCommand.ExecuteNonQuery(); } catch (Exception ex2) { // Breakpoint here if (ex2.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") { Console.WriteLine(ex2.Message); } } finally { if (ownsConnection) connection.Close(); } /// } } // Only update HasBeenOutput and DateAdded if a new post was inserted if (rowsInserted == 1) { try { string updateBlogSql = "UPDATE Blogs SET HasBeenOutput = 0, DateAdded = @DateAdded, DateModified = @DateModified WHERE BlogName = @BlogName"; using (var updateBlogCommand = new SQLiteCommand(updateBlogSql, connection)) { updateBlogCommand.Parameters.AddWithValue("@BlogName", blogName); updateBlogCommand.Parameters.AddWithValue("@DateAdded", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); updateBlogCommand.Parameters.AddWithValue("@DateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); updateBlogCommand.ExecuteNonQuery(); } } catch { } } } finally { if (ownsConnection) connection.Close(); } } public static void AddAPICount(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); // Use INSERT OR IGNORE to avoid UNIQUE constraint errors when the date row already exists. // Also explicitly initialize APICount to 0 in case the table has no default. string sql = "INSERT OR IGNORE INTO DailyAPICount (Date, APICount, DateCreated) values(@date, 0, @DateCreated)"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@date", DateTime.Today.ToShortDateString()); command.Parameters.AddWithValue("@DateCreated", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.ExecuteNonQuery(); } } catch (Exception ex) { // Breakpoint here //Console.WriteLine(ex.Message); } } public static bool AddNote(string rootBlogName, string noteBlogName, long postID, long timestamp, string type, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); //try { AddPost(rootBlogName, postID, DBPath); } catch { } try { AddBlog(noteBlogName, false, DBPath); } catch { } using SQLiteConnection connection2 = new SQLiteConnection("Data Source=" + DBPath); try { connection2.Open(); string sql = "INSERT OR IGNORE INTO Notes (rootBlogName, noteBlogName, PostID, TimeStamp, Type, DatetimeCrawled, DateModified, DateCreated) values(@rootBlogName, @noteBlogName, @PostID, @TimeStamp, @Type, @DatetimeCrawled, @DateModified, @DateCreated)"; using (SQLiteCommand command = new SQLiteCommand(sql, connection2)) { command.Parameters.AddWithValue("@rootBlogName", rootBlogName); command.Parameters.AddWithValue("@noteBlogName", noteBlogName); command.Parameters.AddWithValue("@PostID", postID); command.Parameters.AddWithValue("@TimeStamp", timestamp); command.Parameters.AddWithValue("@Type", type ?? string.Empty); command.Parameters.AddWithValue("@DatetimeCrawled", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@DateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@DateCreated", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); int rowsInserted = command.ExecuteNonQuery(); if (rowsInserted == 1) { ConsoleColor previousColor = Console.ForegroundColor; Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine("{2}\t{0}\t{1}", UnixTimeStampToDateTime(timestamp), noteBlogName, type); Console.ForegroundColor = previousColor; Thread.Sleep(250); // Brief pause to make new notes more noticeable in the console output } else { Console.WriteLine("{2}\t{0}\t{1}", UnixTimeStampToDateTime(timestamp), noteBlogName, type); } // Only update HasBeenOutput if a new note was inserted if (rowsInserted == 1) { try { string updateSql = "UPDATE Blogs SET HasBeenOutput = 0, DateModified = @DateModified WHERE BlogName = @BlogName"; using (var updateCommand = new SQLiteCommand(updateSql, connection2)) { updateCommand.Parameters.AddWithValue("@BlogName", noteBlogName); updateCommand.Parameters.AddWithValue("@DateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); updateCommand.ExecuteNonQuery(); } } catch { } } } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Notes.RootBlogName, Notes.PostID, Notes.TimeStamp, Notes.Type, Notes.NoteBlogName") { Console.WriteLine(ex.Message); Console.WriteLine("^^^^^ - SHORTCUT"); } } return false; } #endregion Adds #region Gets /// /// /// /// /// /// blogName, postID, lastNoteTimestamp, notesGatheredTimestamp public static List> GetPosts(bool withoutNotesOnly = false, DateTime? beforeDate = null, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); List> posts = new List>(); try { connection.Open(); string sql; if (withoutNotesOnly) { string beforeDateFilter = string.Empty; if (beforeDate.HasValue) { long unixTimestamp = new DateTimeOffset(beforeDate.Value).ToUnixTimeSeconds(); beforeDateFilter = $"WHERE (U.NotesGatheredDateTime < {unixTimestamp} OR U.NotesGatheredDateTime IS NULL)" + Environment.NewLine; } sql = "WITH PostsWithCount AS" + Environment.NewLine + "(" + Environment.NewLine + " SELECT " + Environment.NewLine + " P.BlogName," + Environment.NewLine + " P.PostID," + Environment.NewLine + " 1925013599 AS LatestNoteTimestamp," + Environment.NewLine + " P.NotesGatheredDateTime," + Environment.NewLine + " COUNT(P.PostID) OVER(PARTITION BY P.BlogName) AS CNT," + Environment.NewLine + " P.HasNotesGathered," + Environment.NewLine + " P.NotFound," + Environment.NewLine + " P.PostDate" + Environment.NewLine + " FROM Posts P" + Environment.NewLine + ")," + Environment.NewLine + "Unioned AS" + Environment.NewLine + "(" + Environment.NewLine + " SELECT " + Environment.NewLine + " BlogName," + Environment.NewLine + " PostID," + Environment.NewLine + " LatestNoteTimestamp," + Environment.NewLine + " NotesGatheredDateTime," + Environment.NewLine + " CNT," + Environment.NewLine + " PostDate" + Environment.NewLine + " FROM PostsWithCount" + Environment.NewLine + " WHERE NotFound = 0" + Environment.NewLine + " AND HasNotesGathered = 0" + Environment.NewLine + "" + Environment.NewLine + " UNION " + Environment.NewLine + "" + Environment.NewLine + " SELECT " + Environment.NewLine + " BlogName," + Environment.NewLine + " PostID," + Environment.NewLine + " LatestNoteTimestamp," + Environment.NewLine + " NotesGatheredDateTime," + Environment.NewLine + " CNT," + Environment.NewLine + " PostDate" + Environment.NewLine + " FROM PostsWithCount" + Environment.NewLine + " WHERE BlogName = 'zomb-eh'" + Environment.NewLine + " AND NotFound = 0" + Environment.NewLine + " AND NotesGatheredDateTime < unixepoch('now', 'localtime', '-3 days')" + Environment.NewLine + ")" + Environment.NewLine + "SELECT" + Environment.NewLine + " U.BlogName," + Environment.NewLine + " U.PostID," + Environment.NewLine + " U.LatestNoteTimestamp," + Environment.NewLine + " U.NotesGatheredDateTime," + Environment.NewLine + " U.CNT" + Environment.NewLine + "FROM Unioned U" + Environment.NewLine + beforeDateFilter + "ORDER BY U.NotesGatheredDateTime, U.PostDate DESC, U.BlogName, U.PostID;" + Environment.NewLine; } else { sql = "SELECT " + " MAX(Posts.BlogName) as BlogName, " + Environment.NewLine + " Posts.PostID, " + Environment.NewLine + " 1925013599 as LatestNoteTimestamp, " + Environment.NewLine + " Posts.NotesGatheredDatetime, " + Environment.NewLine + " CNT.CNT " + Environment.NewLine + "FROM " + Environment.NewLine + " Posts " + Environment.NewLine + " LEFT OUTER JOIN " + Environment.NewLine + " Notes ON Notes.RootBlogName = Posts.BlogName AND Notes.PostID = Posts.PostID " + Environment.NewLine + " LEFT OUTER JOIN " + Environment.NewLine + " ( select BlogName, count(PostID) as CNT from Posts group by BlogName) CNT on CNT.blogName = Posts.BlogName " + "WHERE NotFound = 0 " + Environment.NewLine; if (beforeDate.HasValue) { long unixTimestamp = new DateTimeOffset(beforeDate.Value).ToUnixTimeSeconds(); sql += $" AND (NotesGatheredDateTime < {unixTimestamp} OR NotesGatheredDateTime= 0) " + Environment.NewLine; } sql += "GROUP BY " + Environment.NewLine + " Posts.BlogName, Posts.PostID " + Environment.NewLine + "ORDER BY " + Environment.NewLine + " notesgathereddatetime, Posts.PostDate desc, Posts.BlogName, Posts.PostID" + Environment.NewLine; } Console.WriteLine(withoutNotesOnly); Console.WriteLine(System.Text.RegularExpressions.Regex.Replace(sql, @"\s+", " ").Trim()); Console.Write(">" ); //Console.ReadKey(); //Thread.Sleep(250); using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { using (SQLiteDataReader reader = command.ExecuteReader()) { while (reader.Read()) { Tuple post = default; string blog; long postID = 0; long lastNoteTimestamp; long notesGatheredTimestamp; // Diagnostic logging before each cast //for (int i = 0; i < reader.FieldCount; i++) //{ // var value = reader.GetValue(i); // var type = reader.GetFieldType(i); // Console.WriteLine($"[GetPosts] Column {i}: Name={reader.GetName(i)}, Value={value}, Type={type}"); //} try { blog = Convert.ToString(reader.GetValue(0)); } catch (Exception ex) { // Breakpoint here Console.WriteLine($"[GetPosts] Error casting column 0 to string: {ex.Message}"); throw; } try { postID = Convert.ToInt64(reader.GetValue(1)); } catch (Exception ex) { // Breakpoint here Console.WriteLine($"[GetPosts] Error casting column 1 to Int64: {ex.Message}"); throw; } try { lastNoteTimestamp = Convert.ToInt64(reader.GetValue(2)); } catch (Exception ex) { // Breakpoint here Console.WriteLine($"[GetPosts] Error casting column 2 to Int64: {ex.Message}"); throw; } try { notesGatheredTimestamp = Convert.ToInt64(reader.GetValue(3)); } catch (Exception ex) { // Breakpoint here Console.WriteLine($"[GetPosts] Error casting column 3 to Int64: {ex.Message}"); throw; } // Exclude if postID is in the exclusion list if (_postIdsToExclude != null && _postIdsToExclude.Contains(postID)) continue; post = new Tuple(blog, postID, lastNoteTimestamp, notesGatheredTimestamp); posts.Add(post); } } } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") Console.WriteLine(ex.Message); } return posts; } public static List> GetReplies(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); List> posts = new List>(); try { connection.Open(); string sql = "SELECT distinct RootBlogName as blogName, postID FROM Notes WHERE Notes.type = 'reply' order by RootBlogName, PostID"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { using (SQLiteDataReader reader = command.ExecuteReader()) { while (reader.Read()) { Tuple post = default; string blog = null; long id = 0; id = reader.GetInt64(reader.GetOrdinal("postID")); // Assuming Id is the first column blog = reader.GetString(reader.GetOrdinal("blogName")); // Assuming Title is the second column post = new Tuple(blog, id); posts.Add(post); } } } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") Console.WriteLine(ex.Message); } return posts; } public static List> GetRepliesWithMissingText(string? DBPath = null, int limit = 50) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); List> posts = new List>(); try { connection.Open(); string sql = @"SELECT DISTINCT Notes.RootBlogName as blogName, Notes.PostID, MAX(Notes.timestamp) as LatestTimestamp FROM Notes WHERE Notes.type = 'reply' AND (Notes.replyText IS NULL OR Notes.replyText = '' OR Notes.replyText = '.') GROUP BY Notes.RootBlogName, Notes.PostID ORDER BY LatestTimestamp ASC LIMIT @limit"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@limit", limit); using (SQLiteDataReader reader = command.ExecuteReader()) { while (reader.Read()) { Tuple post = default; string blog = null; long id = 0; blog = reader.GetString(reader.GetOrdinal("blogName")); id = reader.GetInt64(reader.GetOrdinal("PostID")); post = new Tuple(blog, id); posts.Add(post); } } } } catch (Exception ex) { // Breakpoint here Console.WriteLine($"Error getting replies with missing text: {ex.Message}"); } return posts; } public static List> GetRepliesWithFilledText(string? DBPath = null, int? limit = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); List> posts = new List>(); try { connection.Open(); string sql = @"SELECT DISTINCT P.BlogName, P.PostID, MAX(N.timestamp) as LatestTimestamp FROM Posts P INNER JOIN Notes N ON N.PostID = P.PostID AND N.RootBlogName = P.BlogName WHERE P.NotFound = 0 AND N.type = 'reply' AND (N.replyText IS NULL OR N.replyText = '' OR N.replyText = '.') GROUP BY P.BlogName, P.PostID ORDER BY LatestTimestamp ASC"; if (limit.HasValue) { sql += " LIMIT @limit"; } using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { if (limit.HasValue) { command.Parameters.AddWithValue("@limit", limit.Value); } using (SQLiteDataReader reader = command.ExecuteReader()) { while (reader.Read()) { Tuple post = default; string blog = null; long id = 0; long timestamp = 0; blog = reader.GetString(reader.GetOrdinal("blogName")); id = reader.GetInt64(reader.GetOrdinal("PostID")); timestamp = reader.GetInt64(reader.GetOrdinal("LatestTimestamp")); timestamp++; post = new Tuple(blog, id, timestamp); posts.Add(post); } } } } catch (Exception ex) { // Breakpoint here Console.WriteLine($"Error getting replies with filled text: {ex.Message}"); } return posts; } public static int GetAPICount(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); int count = 0; try { AddAPICount(); } catch { } try { connection.Open(); string sql = "SELECT APICount FROM DailyAPICount WHERE [Date] = @date"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@date", DateTime.Today.ToShortDateString()); using (SQLiteDataReader reader = command.ExecuteReader()) { while (reader.Read()) { count = reader.GetInt32(0); // Assuming Id is the first column } } } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") Console.WriteLine(ex.Message); } return count; } public static List> GetBlogsForLikes(string specificBlog = null, int cooldownDays = 7, bool ignoreCooldown = false, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); List> blogs = new List>(); try { connection.Open(); string sql; if (!string.IsNullOrEmpty(specificBlog)) { // Specific blog: always process, bypass cooldown sql = @"SELECT BlogName, COALESCE(LikesPulled, 0), COALESCE(LikesCursor, 0), COALESCE(LikesNewestTimestamp, 0) FROM Blogs WHERE BlogName = @blog"; } else if (ignoreCooldown) { // All blogs with notes: backfill-pending OR any refresh-eligible blog, ignore cooldown sql = @"SELECT B.BlogName, COALESCE(B.LikesPulled, 0), COALESCE(B.LikesCursor, 0), COALESCE(B.LikesNewestTimestamp, 0) FROM Blogs B INNER JOIN Notes N ON N.NoteBlogName = B.BlogName WHERE N.TimeStamp >= 1535778000 AND N.rootBlogName = B.BlogName GROUP BY B.BlogName ORDER BY MIN(N.Timestamp);"; } else { // Backfill-pending OR refresh-due (past cooldown window) sql = @"SELECT B.BlogName, COALESCE(B.LikesPulled, 0), COALESCE(B.LikesCursor, 0), COALESCE(B.LikesNewestTimestamp, 0) FROM Blogs B INNER JOIN Notes N ON N.NoteBlogName = B.BlogName WHERE N.TimeStamp >= 1535778000 AND N.rootBlogName = B.BlogName AND ( B.LikesPulled = 0 OR COALESCE(B.LikesLastRefreshed, 0) < (CAST(strftime('%s','now') AS INTEGER) - (@cooldownDays * 86400)) ) GROUP BY B.BlogName ORDER BY MIN(N.Timestamp);"; } using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { if (!string.IsNullOrEmpty(specificBlog)) command.Parameters.AddWithValue("@blog", specificBlog); else if (!ignoreCooldown) command.Parameters.AddWithValue("@cooldownDays", cooldownDays); using (SQLiteDataReader reader = command.ExecuteReader()) { while (reader.Read()) { blogs.Add(new Tuple( reader.GetString(0), reader.GetInt32(1), reader.GetInt64(2), reader.GetInt64(3) )); } } } } catch (Exception ex) { Console.WriteLine($"Error fetching blogs for likes: {ex.Message}"); } return blogs; } public static List GetBlogs(bool reblogsOnly, int from, int to, int top, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); List blogs = new List(); try { connection.Open(); string sql = ""; if (reblogsOnly) sql = "SELECT NoteBlogName as blogName, count(*) FROM notes INNER JOIN blogs ON blogs.BlogName = notes.NoteBlogName WHERE blogs.IsActive = @isActive AND type IN ('reblog', 'reply', 'posted') AND HasBeenOutput = 0 GROUP BY NoteBlogName ORDER BY count(*) DESC, BlogName LIMIT @top"; else sql = "SELECT NoteBlogName as blogName, count(*) FROM notes INNER JOIN blogs ON blogs.BlogName = notes.NoteBlogName WHERE blogs.IsActive = @isActive AND type NOT IN ('reblog', 'reply', 'posted') AND HasBeenOutput = 0 GROUP BY NoteBlogName ORDER BY count(*) DESC, BlogName LIMIT @top"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@isActive", 1); command.Parameters.AddWithValue("@top", top); using (SQLiteDataReader reader = command.ExecuteReader()) { while (reader.Read()) { string blog = null; blog = reader.GetString(0); // Assuming Title is the second column blogs.Add(blog); } } } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") Console.WriteLine(ex.Message); } return blogs; } public static List GetBlogsAll(bool reblogsOnly, int from, int to, int top, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); List blogs = new List(); try { connection.Open(); string sql = ""; if (reblogsOnly) sql = "SELECT NoteBlogName as blogName, count(*) FROM notes INNER JOIN blogs ON blogs.BlogName = notes.NoteBlogName WHERE blogs.IsActive = @isActive AND type IN ('reblog', 'reply', 'posted') AND HasBeenOutput = 0 GROUP BY NoteBlogName ORDER BY count(*) DESC, BlogName LIMIT @top"; else sql = "SELECT NoteBlogName as blogName, count(*) FROM notes INNER JOIN blogs ON blogs.BlogName = notes.NoteBlogName WHERE blogs.IsActive = @isActive AND HasBeenOutput = 0 GROUP BY NoteBlogName ORDER BY count(*) DESC, BlogName LIMIT @top"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@isActive", 1); command.Parameters.AddWithValue("@top", top); using (SQLiteDataReader reader = command.ExecuteReader()) { while (reader.Read()) { string blog = null; blog = reader.GetString(0); // Assuming Title is the second column blogs.Add(blog); } } } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") Console.WriteLine(ex.Message); } return blogs; } public static IEnumerable> GetAllPostTextColumns(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); connection.Open(); string sql = "SELECT BlogName, reblogURL, PostURL, Slug, ReblogKey, ReblogName, Summary, Quote, Body, Tags, Link, PhotoURL, PhotoCaption, DownloadedFiles, AudioCaption, Question, Answer, Title, RootBlogName, RootURL FROM Posts WHERE IFNULL(DownloadedFiles, '.') = '.'"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { using (SQLiteDataReader reader = command.ExecuteReader()) { while (reader.Read()) { List rowTexts = new List(); for (int i = 0; i < reader.FieldCount; i++) { if (!reader.IsDBNull(i)) { var val = reader.GetValue(i); if (val is string str && !string.IsNullOrWhiteSpace(str) && str != ".") { rowTexts.Add(str); } } } if (rowTexts.Count > 0) { yield return rowTexts; } } } } } #endregion Gets #region Updates public static void UpdatePostMarkNotesCollected(string blogName, long postID, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); //string sql = "UPDATE Posts SET HasNotesGathered = 1, NotesGatheredDateTime = @notesGathered WHERE BlogName = @BlogName AND PostID = @PostID"; string sql = "UPDATE Posts SET HasNotesGathered = 1, NotesGatheredDateTime = @notesGathered, DateModified = @dateModified WHERE PostID = @PostID AND (IFNULL(HasNotesGathered, 0) <> 1 OR IFNULL(NotesGatheredDateTime, 0) <> @notesGathered)"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@notesGathered", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); command.Parameters.AddWithValue("@dateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@BlogName", blogName); command.Parameters.AddWithValue("@PostID", postID); command.ExecuteNonQuery(); } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") Console.WriteLine(ex.Message); } } public static void UpdatePostMarkNotFound(string blogName, long postID, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { // Informative output when marking a post as NotFound Console.WriteLine($"Marking post NotFound: {blogName}.tumblr.com/post/{postID}"); connection.Open(); string sql = "UPDATE Posts SET NotFound = 1, DateModified = @dateModified WHERE BlogName = @BlogName AND PostID = @PostID AND IFNULL(NotFound, 0) <> 1"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@dateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@BlogName", blogName); command.Parameters.AddWithValue("@PostID", postID); command.ExecuteNonQuery(); } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") Console.WriteLine(ex.Message); } } // ----- CollectRunState: tracks the frozen cutoff + completion flag for a managed "-collect 0" full re-check run ----- // Single-row table (Id = 1), mirroring the ApiKeyPoolMeta pattern. Lets an interrupted run resume against the // same cutoff and lets a completed run stop instead of restarting on the next launch. public static void EnsureCollectRunStateTableExists(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); connection.Open(); string sql = "CREATE TABLE IF NOT EXISTS CollectRunState (" + "Id INTEGER PRIMARY KEY CHECK (Id = 1), " + "RunCutoff INTEGER DEFAULT 0, " + "RunComplete INTEGER DEFAULT 1, " + "RunStarted TEXT, " + "RunCompletedAt TEXT)"; using SQLiteCommand command = new SQLiteCommand(sql, connection); command.ExecuteNonQuery(); } // Returns (RunCutoff unix seconds, RunComplete) for the single run-state row, or null if no row exists yet. public static (long cutoff, bool complete)? GetCollectRunState(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); connection.Open(); using SQLiteCommand command = new SQLiteCommand("SELECT RunCutoff, RunComplete FROM CollectRunState WHERE Id = 1", connection); using SQLiteDataReader reader = command.ExecuteReader(); if (reader.Read()) { long cutoff = Convert.ToInt64(reader.GetValue(0)); bool complete = Convert.ToInt64(reader.GetValue(1)) != 0; return (cutoff, complete); } return null; } // Start (or restart) a managed run: freeze the cutoff and mark the run in progress. public static void BeginCollectRun(long cutoffUnixSeconds, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); connection.Open(); string sql = "INSERT INTO CollectRunState (Id, RunCutoff, RunComplete, RunStarted, RunCompletedAt) " + "VALUES (1, @cutoff, 0, @started, NULL) " + "ON CONFLICT(Id) DO UPDATE SET RunCutoff = @cutoff, RunComplete = 0, RunStarted = @started, RunCompletedAt = NULL"; using SQLiteCommand command = new SQLiteCommand(sql, connection); command.Parameters.AddWithValue("@cutoff", cutoffUnixSeconds); command.Parameters.AddWithValue("@started", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.ExecuteNonQuery(); } // Mark the active managed run complete so the next launch starts fresh instead of resuming. public static void CompleteCollectRun(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); connection.Open(); string sql = "UPDATE CollectRunState SET RunComplete = 1, RunCompletedAt = @completedAt WHERE Id = 1"; using SQLiteCommand command = new SQLiteCommand(sql, connection); command.Parameters.AddWithValue("@completedAt", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.ExecuteNonQuery(); } public static void UpdatePostSetDate(string blogName, long postID, string postDate, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); SQLiteConnection connection; bool ownsConnection; lock (_importSessionLock) { if (_importConnection != null) { connection = _importConnection; ownsConnection = false; } else { connection = new SQLiteConnection("Data Source=" + DBPath); ownsConnection = true; } } try { if (ownsConnection) connection.Open(); string sql = "UPDATE Posts SET postDate = @postDate, DateModified = @dateModified WHERE BlogName = @BlogName AND PostID = @PostID AND IFNULL(postDate, '') <> @postDate"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@postDate", postDate ?? string.Empty); command.Parameters.AddWithValue("@dateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@BlogName", blogName); command.Parameters.AddWithValue("@PostID", postID); command.ExecuteNonQuery(); } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") Console.WriteLine(ex.Message); } finally { if (ownsConnection) connection.Close(); } } public static bool UpdateNote(string rootBlogName, string noteBlogName, long postID, long timestamp, string type, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); //try { AddPost(rootBlogName, postID, DBPath); } catch { } try { AddBlog(noteBlogName, false, DBPath); } catch { } Console.WriteLine("{2}\t{0}\t{1}", UnixTimeStampToDateTime(timestamp), noteBlogName, type); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); string sql = "UPDATE Notes SET timestamp = @timestamp, DateModified = @dateModified WHERE rootBlogName = @rootBlogName AND noteBlogName = @noteBlogName AND PostID = @postID AND IFNULL(timestamp, 0) <> @timestamp"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@timestamp", timestamp); command.Parameters.AddWithValue("@dateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@rootBlogName", rootBlogName); command.Parameters.AddWithValue("@noteBlogName", noteBlogName); command.Parameters.AddWithValue("@postID", postID); command.ExecuteNonQuery(); } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Notes.RootBlogName, Notes.PostID, Notes.TimeStamp, Notes.Type, Notes.NoteBlogName") { Console.WriteLine(ex.Message); Console.WriteLine("^^^^^ - SHORTCUT"); return true; } } return false; } public static void UpdatePost(string blogName, long postID, string reblogURL, string postDate, string postURL, string slug, string reblogKey, string reblogName, string summary, string quote, string body, string tags, string link, string photoURL, string photoCaption, string downloadedFiles, string audioCaption, string question, string answer, string title, bool hasImage, bool byLikes = false, string? DBPath = null, string? rootBlogName = null, string? rootURL = null) { DBPath ??= GetDefaultDbPath(); SQLiteConnection connection; bool ownsConnection; lock (_importSessionLock) { if (_importConnection != null) { connection = _importConnection; ownsConnection = false; } else { connection = new SQLiteConnection("Data Source=" + DBPath); ownsConnection = true; } } try { if (ownsConnection) connection.Open(); string sql = "UPDATE Posts SET "; sql += "postDate = @postDate, "; sql += "reblogURL = @reblogURL, "; sql += "postURL = @postURL, "; sql += "slug = @slug, "; sql += "reblogKey = @reblogKey, "; sql += "reblogName = @reblogName, "; sql += "summary = @summary, "; sql += "quote = @quote, "; sql += "body = @body, "; sql += "tags = @tags, "; sql += "link = @link, "; sql += "photoURL = @photoURL, "; sql += "photoCaption = @photoCaption, "; sql += "downloadedFiles = @downloadedFiles, "; sql += "audioCaption = @audioCaption, "; sql += "question = @question, "; sql += "answer = @answer, "; sql += "title = @title, "; sql += "DateModified = @dateModified, "; sql += "RootBlogName = CASE WHEN @rootBlogName IS NULL OR @rootBlogName = '' OR @rootBlogName = '.' THEN RootBlogName ELSE @rootBlogName END, "; sql += "RootURL = CASE WHEN @rootURL IS NULL OR @rootURL = '' OR @rootURL = '.' THEN RootURL ELSE @rootURL END, "; sql += "hasImage = @hasImage, "; sql += "ByLikes = MAX(IFNULL(ByLikes, 0), @byLikes) "; sql += " WHERE BlogName = @BlogName AND PostID = @PostID AND ("; sql += "IFNULL(postDate, '') <> @postDate OR "; sql += "IFNULL(reblogURL, '') <> @reblogURL OR "; sql += "IFNULL(postURL, '') <> @postURL OR "; sql += "IFNULL(slug, '') <> @slug OR "; sql += "IFNULL(reblogKey, '') <> @reblogKey OR "; sql += "IFNULL(reblogName, '') <> @reblogName OR "; sql += "IFNULL(summary, '') <> @summary OR "; sql += "IFNULL(quote, '') <> @quote OR "; sql += "IFNULL(body, '') <> @body OR "; sql += "IFNULL(tags, '') <> @tags OR "; sql += "IFNULL(link, '') <> @link OR "; sql += "IFNULL(photoURL, '') <> @photoURL OR "; sql += "IFNULL(photoCaption, '') <> @photoCaption OR "; sql += "IFNULL(downloadedFiles, '') <> @downloadedFiles OR "; sql += "IFNULL(audioCaption, '') <> @audioCaption OR "; sql += "IFNULL(question, '') <> @question OR "; sql += "IFNULL(answer, '') <> @answer OR "; sql += "IFNULL(title, '') <> @title OR "; sql += "IFNULL(hasImage, 0) <> @hasImage OR "; sql += "(@byLikes = 1 AND IFNULL(ByLikes, 0) = 0) OR "; sql += "((@rootBlogName IS NOT NULL AND @rootBlogName <> '' AND @rootBlogName <> '.') AND IFNULL(RootBlogName, '') <> @rootBlogName) OR "; sql += "((@rootURL IS NOT NULL AND @rootURL <> '' AND @rootURL <> '.') AND IFNULL(RootURL, '') <> @rootURL)"; sql += ")"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@postDate", postDate ?? string.Empty); command.Parameters.AddWithValue("@reblogURL", reblogURL ?? string.Empty); command.Parameters.AddWithValue("@postURL", postURL ?? string.Empty); command.Parameters.AddWithValue("@slug", slug ?? string.Empty); command.Parameters.AddWithValue("@reblogKey", reblogKey ?? string.Empty); command.Parameters.AddWithValue("@reblogName", reblogName ?? string.Empty); command.Parameters.AddWithValue("@summary", summary ?? string.Empty); command.Parameters.AddWithValue("@quote", quote ?? string.Empty); command.Parameters.AddWithValue("@body", body ?? string.Empty); command.Parameters.AddWithValue("@tags", tags ?? string.Empty); command.Parameters.AddWithValue("@link", link ?? string.Empty); command.Parameters.AddWithValue("@photoURL", photoURL ?? string.Empty); command.Parameters.AddWithValue("@photoCaption", photoCaption ?? string.Empty); command.Parameters.AddWithValue("@downloadedFiles", downloadedFiles ?? string.Empty); command.Parameters.AddWithValue("@audioCaption", audioCaption ?? string.Empty); command.Parameters.AddWithValue("@question", question ?? string.Empty); command.Parameters.AddWithValue("@answer", answer ?? string.Empty); command.Parameters.AddWithValue("@title", title ?? string.Empty); command.Parameters.AddWithValue("@dateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@rootBlogName", string.IsNullOrWhiteSpace(rootBlogName) ? (object)DBNull.Value : rootBlogName); command.Parameters.AddWithValue("@rootURL", string.IsNullOrWhiteSpace(rootURL) ? (object)DBNull.Value : rootURL); command.Parameters.AddWithValue("@hasImage", hasImage ? 1 : 0); command.Parameters.AddWithValue("@byLikes", byLikes ? 1 : 0); command.Parameters.AddWithValue("@BlogName", blogName); command.Parameters.AddWithValue("@PostID", postID); command.ExecuteNonQuery(); } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") Console.WriteLine(ex.Message); } finally { if (ownsConnection) connection.Close(); } } public static void UpdateBlogOutput(string blogName, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); string sql = "UPDATE Blogs SET HasBeenOutput = 1, DateModified = @DateModified WHERE BlogName = @BlogName AND IFNULL(HasBeenOutput, 0) <> 1"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@BlogName", blogName); command.Parameters.AddWithValue("@DateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.ExecuteNonQuery(); } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") Console.WriteLine(ex.Message); } } public static void UpdateBlogLikesStatus(string blogName, int likesPulled, long likesCursor, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); string sql = "UPDATE Blogs SET LikesPulled = @pulled, LikesCursor = @cursor, DateModified = @modified WHERE BlogName = @name AND (IFNULL(LikesPulled, 0) <> @pulled OR IFNULL(LikesCursor, 0) <> @cursor)"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@pulled", likesPulled); command.Parameters.AddWithValue("@cursor", likesCursor); command.Parameters.AddWithValue("@modified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@name", blogName); command.ExecuteNonQuery(); } } catch (Exception ex) { Console.WriteLine($"Error updating blog likes status: {ex.Message}"); } } // Bumps the high-water mark for a blog. Used during Branch A (initial backfill) when we // capture the newest liked_timestamp on the first page so subsequent refresh runs have a // stopping point. MAX(...) protects against out-of-order updates. public static void UpdateBlogLikesNewestTimestamp(string blogName, long newestTimestamp, string? DBPath = null) { if (newestTimestamp <= 0) return; DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); string sql = @"UPDATE Blogs SET LikesNewestTimestamp = MAX(COALESCE(LikesNewestTimestamp, 0), @newest), DateModified = @modified WHERE BlogName = @name"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@newest", newestTimestamp); command.Parameters.AddWithValue("@modified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@name", blogName); command.ExecuteNonQuery(); } } catch (Exception ex) { Console.WriteLine($"Error updating blog likes newest timestamp: {ex.Message}"); } } // Called at the end of a refresh pass (Branch B). Bumps the high-water mark, stamps the // last-refreshed time so cooldown takes effect, and records how many new likes were found. public static void UpdateBlogLikesRefreshStatus(string blogName, long newestTimestamp, int newCount, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); string sql = @"UPDATE Blogs SET LikesNewestTimestamp = MAX(COALESCE(LikesNewestTimestamp, 0), @newest), LikesLastRefreshed = CAST(strftime('%s','now') AS INTEGER), LikesLastNewCount = @count, DateModified = @modified WHERE BlogName = @name"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@newest", newestTimestamp); command.Parameters.AddWithValue("@count", newCount); command.Parameters.AddWithValue("@modified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@name", blogName); command.ExecuteNonQuery(); } } catch (Exception ex) { Console.WriteLine($"Error updating blog likes refresh status: {ex.Message}"); } } public static int UpdateAPICount(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); int APICount = DataAccess.GetAPICount(); APICount++; try { connection.Open(); string sql = "UPDATE DailyAPICount SET APICount = @APICount WHERE [Date] = @date"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@APICount", APICount); command.Parameters.AddWithValue("@date", DateTime.Today.ToShortDateString()); command.ExecuteNonQuery(); } } catch (Exception ex) { // Breakpoint here if (ex.Message != "constraint failed\r\nUNIQUE constraint failed: Posts.BlogName, Posts.PostID") Console.WriteLine(ex.Message); } return APICount; } public static int UpdateNoteReplyText(string rootBlogName, long postID, string noteBlogName, long timestamp, string replyText, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); int rowsAffected = 0; // A bare "." collides with the "needs processing" sentinel in GetRepliesWithFilledText, which would loop the post forever. Store as ". " so the data is preserved but no longer matches the sentinel. if (replyText == ".") replyText = ". "; try { connection.Open(); //string sql = "UPDATE Notes SET replyText = @replyText WHERE rootBlogName = @rootBlogName AND PostID = @PostID AND noteBlogName = @noteBlogName AND TimeStamp = @TimeStamp AND Type = 'reply'"; // Match on (noteBlogName, TimeStamp ±5s) only - a reply by a given blog at a given timestamp is the same reply across the original post and every reblog of it, so this fans out across reblog chains in one shot. Tolerance absorbs the ~1s drift between what -collect stored and what mode=conversation returns now. // Only fan out to rows that match the SELECT criteria in GetRepliesWithFilledText (NULL/empty/legacy-'.'). Never overwrite '?' (confirmed-empty) or already-fetched text. string sql = "UPDATE Notes SET replyText = @replyText, DateModified = @dateModified WHERE noteBlogName = @noteBlogName AND ABS(TimeStamp - @TimeStamp) <= 5 AND Type = 'reply' AND (replyText IS NULL OR replyText = '' OR replyText = '.')"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@replyText", replyText ?? "?"); command.Parameters.AddWithValue("@dateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@rootBlogName", rootBlogName); command.Parameters.AddWithValue("@PostID", postID); command.Parameters.AddWithValue("@noteBlogName", noteBlogName); command.Parameters.AddWithValue("@TimeStamp", timestamp); rowsAffected = command.ExecuteNonQuery(); if (rowsAffected == 0) { Console.WriteLine($"[UpdateNoteReplyText] INFO: No rows updated for {rootBlogName}.tumblr.com/post/{postID} from {noteBlogName} at {UnixTimeStampToDateTime(timestamp)} (row not found or value unchanged)"); Console.WriteLine($"[UpdateNoteReplyText] Query: {sql}"); Console.WriteLine($"[UpdateNoteReplyText] Params: rootBlogName={rootBlogName}, PostID={postID}, noteBlogName={noteBlogName}, TimeStamp={timestamp}"); } else { Console.WriteLine($"[UpdateNoteReplyText] Successfully updated {rowsAffected} row(s) for {rootBlogName}.tumblr.com/post/{postID} from {noteBlogName}"); } } } catch (Exception ex) { // Breakpoint here Console.WriteLine($"[UpdateNoteReplyText] Error updating reply text: {ex.Message}"); Console.WriteLine($"[UpdateNoteReplyText] StackTrace: {ex.StackTrace}"); } return rowsAffected; } public static int UpdateAllNoteReplyTextForPost(string rootBlogName, long postID, string replyText, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); string sql = "UPDATE Notes SET replyText = @replyText, DateModified = @dateModified WHERE rootBlogName = @rootBlogName AND PostID = @PostID AND Type = 'reply' AND IFNULL(replyText, '.') <> @replyText"; using (SQLiteCommand command = new SQLiteCommand(sql, connection)) { command.Parameters.AddWithValue("@replyText", replyText ?? "."); command.Parameters.AddWithValue("@dateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.Parameters.AddWithValue("@rootBlogName", rootBlogName); command.Parameters.AddWithValue("@PostID", postID); int rowsAffected = command.ExecuteNonQuery(); if (rowsAffected == 0) { Console.WriteLine($"[UpdateAllNoteReplyTextForPost] INFO: No rows updated for {rootBlogName}.tumblr.com/post/{postID} (rows not found or values unchanged)"); Console.WriteLine($"[UpdateAllNoteReplyTextForPost] Query: {sql}"); Console.WriteLine($"[UpdateAllNoteReplyTextForPost] Params: rootBlogName={rootBlogName}, PostID={postID}, replyText={replyText}"); } else { Console.WriteLine($"[UpdateAllNoteReplyTextForPost] Successfully updated {rowsAffected} row(s) for {rootBlogName}.tumblr.com/post/{postID} with '{replyText}'"); } return rowsAffected; } } catch (Exception ex) { // Breakpoint here Console.WriteLine($"[UpdateAllNoteReplyTextForPost] Error updating reply text: {ex.Message}"); Console.WriteLine($"[UpdateAllNoteReplyTextForPost] StackTrace: {ex.StackTrace}"); return 0; } } #endregion Updates #region TTFileHelper // Idempotent migration: adds Blogs.TTFolderPath and Posts.PostType if missing. // Mirrors the EnsureBlogsLikesColumnsExist pattern (PRAGMA table_info + ALTER TABLE ADD COLUMN). public static void EnsureTTFileHelperColumnsExist(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath); try { connection.Open(); bool ttFolderPathExists = false; using (SQLiteCommand command = new SQLiteCommand("PRAGMA table_info(Blogs);", connection)) using (SQLiteDataReader reader = command.ExecuteReader()) { while (reader.Read()) { string columnName = reader.GetString(1); if (columnName.Equals("TTFolderPath", StringComparison.OrdinalIgnoreCase)) ttFolderPathExists = true; } } if (!ttFolderPathExists) { using (SQLiteCommand cmd = new SQLiteCommand("ALTER TABLE Blogs ADD COLUMN TTFolderPath TEXT;", connection)) cmd.ExecuteNonQuery(); Console.WriteLine("[Migration] Added TTFolderPath column to Blogs table"); } bool postTypeExists = false; using (SQLiteCommand command = new SQLiteCommand("PRAGMA table_info(Posts);", connection)) using (SQLiteDataReader reader = command.ExecuteReader()) { while (reader.Read()) { string columnName = reader.GetString(1); if (columnName.Equals("PostType", StringComparison.OrdinalIgnoreCase)) postTypeExists = true; } } if (!postTypeExists) { using (SQLiteCommand cmd = new SQLiteCommand("ALTER TABLE Posts ADD COLUMN PostType TEXT;", connection)) cmd.ExecuteNonQuery(); Console.WriteLine("[Migration] Added PostType column to Posts table"); } } catch (Exception ex) { Console.WriteLine($"Error ensuring TTFileHelper columns: {ex.Message}"); } } // INSERT-or-UPDATE for a post arriving from a Tumblr text-file export. // On collision, only content columns + PostType + DateModified are updated; // engagement columns (ByLikes, RootBlogName, RootURL, HasNotesGathered, NotFound, // NotesGatheredDateTime) are preserved. public static void UpsertPostFromTextFile( string blogName, string postID, string? reblogURL, string? postDate, string? postURL, string? slug, string? reblogKey, string? reblogName, string? summary, string? quote, string? body, string? tags, string? link, string? photoURL, string? photoCaption, string? downloadedFiles, string? audioCaption, string? question, string? answer, string? title, string? postType, bool hasImage, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); try { AddBlog(blogName, false, DBPath); } catch { } SQLiteConnection connection; bool ownsConnection; lock (_importSessionLock) { if (_importConnection != null) { connection = _importConnection; ownsConnection = false; } else { connection = new SQLiteConnection("Data Source=" + DBPath); ownsConnection = true; } } string now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); try { if (ownsConnection) connection.Open(); string insertSql = @"INSERT INTO Posts ( BlogName, PostID, reblogURL, PostDate, PostURL, Slug, ReblogKey, ReblogName, Summary, Quote, Body, Tags, Link, PhotoURL, PhotoCaption, DownloadedFiles, AudioCaption, Question, Answer, Title, PostType, DateModified, DateCreated, RootBlogName, RootURL, HasImage, ByLikes ) VALUES ( @BlogName, @PostID, @reblogURL, @PostDate, @PostURL, @Slug, @ReblogKey, @ReblogName, @Summary, @Quote, @Body, @Tags, @Link, @PhotoURL, @PhotoCaption, @DownloadedFiles, @AudioCaption, @Question, @Answer, @Title, @PostType, @DateModified, @DateCreated, '.', '.', @HasImage, 0)"; int rowsInserted = 0; try { using (var cmd = new SQLiteCommand(insertSql, connection)) { cmd.Parameters.AddWithValue("@BlogName", blogName); cmd.Parameters.AddWithValue("@PostID", postID); cmd.Parameters.AddWithValue("@reblogURL", (object?)reblogURL ?? DBNull.Value); cmd.Parameters.AddWithValue("@PostDate", (object?)postDate ?? DBNull.Value); cmd.Parameters.AddWithValue("@PostURL", (object?)postURL ?? DBNull.Value); cmd.Parameters.AddWithValue("@Slug", (object?)slug ?? DBNull.Value); cmd.Parameters.AddWithValue("@ReblogKey", (object?)reblogKey ?? DBNull.Value); cmd.Parameters.AddWithValue("@ReblogName", (object?)reblogName ?? DBNull.Value); cmd.Parameters.AddWithValue("@Summary", (object?)summary ?? DBNull.Value); cmd.Parameters.AddWithValue("@Quote", (object?)quote ?? DBNull.Value); cmd.Parameters.AddWithValue("@Body", (object?)body ?? DBNull.Value); cmd.Parameters.AddWithValue("@Tags", (object?)tags ?? DBNull.Value); cmd.Parameters.AddWithValue("@Link", (object?)link ?? DBNull.Value); cmd.Parameters.AddWithValue("@PhotoURL", (object?)photoURL ?? DBNull.Value); cmd.Parameters.AddWithValue("@PhotoCaption", (object?)photoCaption ?? DBNull.Value); cmd.Parameters.AddWithValue("@DownloadedFiles", (object?)downloadedFiles ?? DBNull.Value); cmd.Parameters.AddWithValue("@AudioCaption", (object?)audioCaption ?? DBNull.Value); cmd.Parameters.AddWithValue("@Question", (object?)question ?? DBNull.Value); cmd.Parameters.AddWithValue("@Answer", (object?)answer ?? DBNull.Value); cmd.Parameters.AddWithValue("@Title", (object?)title ?? DBNull.Value); cmd.Parameters.AddWithValue("@PostType", (object?)postType ?? DBNull.Value); cmd.Parameters.AddWithValue("@DateModified", now); cmd.Parameters.AddWithValue("@DateCreated", now); cmd.Parameters.AddWithValue("@HasImage", hasImage ? 1 : 0); rowsInserted = cmd.ExecuteNonQuery(); } } catch (Exception insertEx) { string msg = insertEx.Message ?? string.Empty; bool isUniqueViolation = msg.Contains("UNIQUE constraint failed"); if (!isUniqueViolation) { Console.WriteLine($"[UpsertPostFromTextFile] INSERT failed for {blogName}/{postID}: {msg}"); } } if (rowsInserted == 0) { string updateSql = @"UPDATE Posts SET reblogURL = @reblogURL, PostDate = @PostDate, PostURL = @PostURL, Slug = @Slug, ReblogKey = @ReblogKey, ReblogName = @ReblogName, Summary = @Summary, Quote = @Quote, Body = @Body, Tags = @Tags, Link = @Link, PhotoURL = @PhotoURL, PhotoCaption = @PhotoCaption, DownloadedFiles = @DownloadedFiles, AudioCaption = @AudioCaption, Question = @Question, Answer = @Answer, Title = @Title, PostType = @PostType, HasImage = @HasImage, DateModified = @DateModified WHERE BlogName = @BlogName AND PostID = @PostID"; using (var cmd = new SQLiteCommand(updateSql, connection)) { cmd.Parameters.AddWithValue("@BlogName", blogName); cmd.Parameters.AddWithValue("@PostID", postID); cmd.Parameters.AddWithValue("@reblogURL", (object?)reblogURL ?? DBNull.Value); cmd.Parameters.AddWithValue("@PostDate", (object?)postDate ?? DBNull.Value); cmd.Parameters.AddWithValue("@PostURL", (object?)postURL ?? DBNull.Value); cmd.Parameters.AddWithValue("@Slug", (object?)slug ?? DBNull.Value); cmd.Parameters.AddWithValue("@ReblogKey", (object?)reblogKey ?? DBNull.Value); cmd.Parameters.AddWithValue("@ReblogName", (object?)reblogName ?? DBNull.Value); cmd.Parameters.AddWithValue("@Summary", (object?)summary ?? DBNull.Value); cmd.Parameters.AddWithValue("@Quote", (object?)quote ?? DBNull.Value); cmd.Parameters.AddWithValue("@Body", (object?)body ?? DBNull.Value); cmd.Parameters.AddWithValue("@Tags", (object?)tags ?? DBNull.Value); cmd.Parameters.AddWithValue("@Link", (object?)link ?? DBNull.Value); cmd.Parameters.AddWithValue("@PhotoURL", (object?)photoURL ?? DBNull.Value); cmd.Parameters.AddWithValue("@PhotoCaption", (object?)photoCaption ?? DBNull.Value); cmd.Parameters.AddWithValue("@DownloadedFiles", (object?)downloadedFiles ?? DBNull.Value); cmd.Parameters.AddWithValue("@AudioCaption", (object?)audioCaption ?? DBNull.Value); cmd.Parameters.AddWithValue("@Question", (object?)question ?? DBNull.Value); cmd.Parameters.AddWithValue("@Answer", (object?)answer ?? DBNull.Value); cmd.Parameters.AddWithValue("@Title", (object?)title ?? DBNull.Value); cmd.Parameters.AddWithValue("@PostType", (object?)postType ?? DBNull.Value); cmd.Parameters.AddWithValue("@HasImage", hasImage ? 1 : 0); cmd.Parameters.AddWithValue("@DateModified", now); cmd.ExecuteNonQuery(); } } } finally { if (ownsConnection) connection.Close(); } } public static List GetAllPostsForBlog(string blogName, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); var results = new List(); using var connection = new SQLiteConnection("Data Source=" + DBPath); connection.Open(); string sql = @"SELECT BlogName, PostID, reblogURL, PostDate, PostURL, Slug, ReblogKey, ReblogName, Summary, Quote, Body, Tags, Link, PhotoURL, PhotoCaption, DownloadedFiles, AudioCaption, Question, Answer, Title, PostType, HasImage, DateCreated, DateModified FROM Posts WHERE BlogName = @BlogName"; using var cmd = new SQLiteCommand(sql, connection); cmd.Parameters.AddWithValue("@BlogName", blogName); using var reader = cmd.ExecuteReader(); while (reader.Read()) { results.Add(new TTPostRecord { BlogName = SafeStr(reader, 0), PostId = SafeStr(reader, 1), ReblogUrl = SafeNullableStr(reader, 2), Date = SafeNullableStr(reader, 3), PostUrl = SafeNullableStr(reader, 4), Slug = SafeNullableStr(reader, 5), ReblogKey = SafeNullableStr(reader, 6), ReblogName = SafeNullableStr(reader, 7), Summary = SafeNullableStr(reader, 8), Quote = SafeNullableStr(reader, 9), Body = SafeNullableStr(reader, 10), Tags = SafeNullableStr(reader, 11), Link = SafeNullableStr(reader, 12), PhotoUrl = SafeNullableStr(reader, 13), PhotoCaption = SafeNullableStr(reader, 14), DownloadedFiles = SafeNullableStr(reader, 15), AudioCaption = SafeNullableStr(reader, 16), Question = SafeNullableStr(reader, 17), Answer = SafeNullableStr(reader, 18), Title = SafeNullableStr(reader, 19), PostType = SafeNullableStr(reader, 20), HasImage = SafeNullableStr(reader, 21), CreatedDate = SafeNullableStr(reader, 22), ModifiedDate = SafeNullableStr(reader, 23) }); } return results; } public static TTPostRecord? GetPost(string blogName, string postId, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using var connection = new SQLiteConnection("Data Source=" + DBPath); connection.Open(); string sql = @"SELECT BlogName, PostID, reblogURL, PostDate, PostURL, Slug, ReblogKey, ReblogName, Summary, Quote, Body, Tags, Link, PhotoURL, PhotoCaption, DownloadedFiles, AudioCaption, Question, Answer, Title, PostType, HasImage, DateCreated, DateModified FROM Posts WHERE BlogName = @BlogName AND PostID = @PostID"; using var cmd = new SQLiteCommand(sql, connection); cmd.Parameters.AddWithValue("@BlogName", blogName); cmd.Parameters.AddWithValue("@PostID", postId); using var reader = cmd.ExecuteReader(); if (!reader.Read()) return null; return new TTPostRecord { BlogName = SafeStr(reader, 0), PostId = SafeStr(reader, 1), ReblogUrl = SafeNullableStr(reader, 2), Date = SafeNullableStr(reader, 3), PostUrl = SafeNullableStr(reader, 4), Slug = SafeNullableStr(reader, 5), ReblogKey = SafeNullableStr(reader, 6), ReblogName = SafeNullableStr(reader, 7), Summary = SafeNullableStr(reader, 8), Quote = SafeNullableStr(reader, 9), Body = SafeNullableStr(reader, 10), Tags = SafeNullableStr(reader, 11), Link = SafeNullableStr(reader, 12), PhotoUrl = SafeNullableStr(reader, 13), PhotoCaption = SafeNullableStr(reader, 14), DownloadedFiles = SafeNullableStr(reader, 15), AudioCaption = SafeNullableStr(reader, 16), Question = SafeNullableStr(reader, 17), Answer = SafeNullableStr(reader, 18), Title = SafeNullableStr(reader, 19), PostType = SafeNullableStr(reader, 20), HasImage = SafeNullableStr(reader, 21), CreatedDate = SafeNullableStr(reader, 22), ModifiedDate = SafeNullableStr(reader, 23) }; } // Find a post by PostID alone (matches ThreeTxtFileHelper's correction-mode // lookup, which does not scope by blog name). Returns the first match if any. public static TTPostRecord? GetPostByIdAnyBlog(string postId, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); using var connection = new SQLiteConnection("Data Source=" + DBPath); connection.Open(); string sql = @"SELECT BlogName, PostID, reblogURL, PostDate, PostURL, Slug, ReblogKey, ReblogName, Summary, Quote, Body, Tags, Link, PhotoURL, PhotoCaption, DownloadedFiles, AudioCaption, Question, Answer, Title, PostType, HasImage, DateCreated, DateModified FROM Posts WHERE PostID = @PostID LIMIT 1"; using var cmd = new SQLiteCommand(sql, connection); cmd.Parameters.AddWithValue("@PostID", postId); using var reader = cmd.ExecuteReader(); if (!reader.Read()) return null; return new TTPostRecord { BlogName = SafeStr(reader, 0), PostId = SafeStr(reader, 1), ReblogUrl = SafeNullableStr(reader, 2), Date = SafeNullableStr(reader, 3), PostUrl = SafeNullableStr(reader, 4), Slug = SafeNullableStr(reader, 5), ReblogKey = SafeNullableStr(reader, 6), ReblogName = SafeNullableStr(reader, 7), Summary = SafeNullableStr(reader, 8), Quote = SafeNullableStr(reader, 9), Body = SafeNullableStr(reader, 10), Tags = SafeNullableStr(reader, 11), Link = SafeNullableStr(reader, 12), PhotoUrl = SafeNullableStr(reader, 13), PhotoCaption = SafeNullableStr(reader, 14), DownloadedFiles = SafeNullableStr(reader, 15), AudioCaption = SafeNullableStr(reader, 16), Question = SafeNullableStr(reader, 17), Answer = SafeNullableStr(reader, 18), Title = SafeNullableStr(reader, 19), PostType = SafeNullableStr(reader, 20), HasImage = SafeNullableStr(reader, 21), CreatedDate = SafeNullableStr(reader, 22), ModifiedDate = SafeNullableStr(reader, 23) }; } public static void SetBlogTTFolderPath(string blogName, string? path, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); try { AddBlog(blogName, false, DBPath); } catch { } using var connection = new SQLiteConnection("Data Source=" + DBPath); connection.Open(); using var cmd = new SQLiteCommand( "UPDATE Blogs SET TTFolderPath = @path, DateModified = @modified WHERE BlogName = @name", connection); cmd.Parameters.AddWithValue("@path", (object?)path ?? DBNull.Value); cmd.Parameters.AddWithValue("@modified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); cmd.Parameters.AddWithValue("@name", blogName); cmd.ExecuteNonQuery(); } // Partial UPDATE used by the correct-apply path. fieldsToUpdate maps // ThreeTxtFileHelper prefix names ("Reblog URL", "Body", etc.) to non-empty // values pulled from a BAK file. Only those columns + DateModified are written; // other content columns and all engagement columns are left intact. // Returns true if a row was matched (and therefore updated). public static bool UpdatePostContentFields(string blogName, string postId, IDictionary fieldsToUpdate, string? DBPath = null) { DBPath ??= GetDefaultDbPath(); var setClauses = new List(); var parameters = new List<(string Name, object Value)>(); foreach (var kvp in fieldsToUpdate) { if (string.IsNullOrWhiteSpace(kvp.Value)) continue; string? column = MapPrefixToColumn(kvp.Key); if (column == null) continue; string paramName = "@p" + parameters.Count; setClauses.Add($"{column} = {paramName}"); parameters.Add((paramName, kvp.Value)); } if (setClauses.Count == 0) return false; setClauses.Add("DateModified = @DateModified"); parameters.Add(("@DateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))); using var connection = new SQLiteConnection("Data Source=" + DBPath); connection.Open(); string sql = $"UPDATE Posts SET {string.Join(", ", setClauses)} WHERE BlogName = @BlogName AND PostID = @PostID"; using var cmd = new SQLiteCommand(sql, connection); foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value); cmd.Parameters.AddWithValue("@BlogName", blogName); cmd.Parameters.AddWithValue("@PostID", postId); return cmd.ExecuteNonQuery() > 0; } private static string? MapPrefixToColumn(string prefix) { return prefix.ToLowerInvariant() switch { "reblog url" => "reblogURL", "date" => "PostDate", "has image" => "HasImage", "post url" => "PostURL", "slug" => "Slug", "reblog key" => "ReblogKey", "reblog name" => "ReblogName", "summary" => "Summary", "quote" => "Quote", "body" => "Body", "tags" => "Tags", "link" => "Link", "photo url" => "PhotoURL", "photo caption" => "PhotoCaption", "downloaded files" => "DownloadedFiles", "audio caption" => "AudioCaption", "question" => "Question", "answer" => "Answer", "title" => "Title", "post id" => null, _ => null }; } public static List<(string BlogName, string? TTFolderPath)> GetAllBlogsWithTTFolderPath(string? DBPath = null) { DBPath ??= GetDefaultDbPath(); var results = new List<(string, string?)>(); using var connection = new SQLiteConnection("Data Source=" + DBPath); connection.Open(); using var cmd = new SQLiteCommand("SELECT BlogName, TTFolderPath FROM Blogs", connection); using var reader = cmd.ExecuteReader(); while (reader.Read()) { string name = reader.GetString(0); string? path = reader.IsDBNull(1) ? null : reader.GetString(1); results.Add((name, path)); } return results; } private static string SafeStr(SQLiteDataReader reader, int ordinal) { return reader.IsDBNull(ordinal) ? string.Empty : reader.GetValue(ordinal)?.ToString() ?? string.Empty; } private static string? SafeNullableStr(SQLiteDataReader reader, int ordinal) { return reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal)?.ToString(); } #endregion TTFileHelper } // Mirrors ThreeTxtFileHelper.PostData so the ported ingest/output/correct logic // can read/write rows without an ORM. All content fields are nullable except keys. public class TTPostRecord { public string BlogName { get; set; } = ""; public string PostId { get; set; } = ""; public string? ReblogUrl { get; set; } public string? Date { get; set; } public string? HasImage { get; set; } public string? PostUrl { get; set; } public string? Slug { get; set; } public string? ReblogKey { get; set; } public string? ReblogName { get; set; } public string? Summary { get; set; } public string? Quote { get; set; } public string? Body { get; set; } public string? Tags { get; set; } public string? Link { get; set; } public string? PhotoUrl { get; set; } public string? PhotoCaption { get; set; } public string? DownloadedFiles { get; set; } public string? AudioCaption { get; set; } public string? Question { get; set; } public string? Answer { get; set; } public string? Title { get; set; } public string? PostType { get; set; } public string? CreatedDate { get; set; } public string? ModifiedDate { get; set; } } public class ApiKeyConfig { public string SectionName { get; set; } = string.Empty; public int KeyNumber { get; set; } public string ConsumerKey { get; set; } = string.Empty; public string ConsumerSecret { get; set; } = string.Empty; public string OAuthToken { get; set; } = string.Empty; public string OAuthTokenSecret { get; set; } = string.Empty; public bool PoolEnabled { get; set; } public string ColorName { get; set; } = string.Empty; public ConsoleColor ParsedColor { get; set; } = ConsoleColor.White; } internal class ApiKeyPool { private static List _keys = new(); private static ApiKeyConfig? _overrideKey = null; private static bool _usePool = false; private static int _currentIndex = 0; private static string _dbPath = string.Empty; private static ApiKeyConfig? _activeKey = null; private static readonly string[] DefaultAutoColors = { "Cyan", "Yellow", "Green", "Magenta", "Blue", "Red", "DarkCyan", "DarkYellow", "DarkGreen", "DarkMagenta" }; private static int _nextAutoColorIndex = 0; private static string _configFilePath = string.Empty; public static bool IsPoolActive => _usePool; public static List Keys => _keys; public static void Initialize(IConfiguration config, string? dbPath, string configFilePath, string? overrideSection = null) { _dbPath = dbPath ?? throw new ArgumentNullException(nameof(dbPath)); _configFilePath = configFilePath; EnsureStateTableExists(); if (!string.IsNullOrEmpty(overrideSection)) { var section = config.GetSection(overrideSection); if (section["ConsumerKey"] == null) throw new InvalidOperationException($"API section '{overrideSection}' not found in appsettings.json"); var colorName = section["Color"] ?? string.Empty; var parsedColor = ParseColor(colorName); _overrideKey = new ApiKeyConfig { SectionName = overrideSection, KeyNumber = 1, ConsumerKey = section["ConsumerKey"]!, ConsumerSecret = section["ConsumerSecret"]!, OAuthToken = section["OAuthToken"]!, OAuthTokenSecret = section["OAuthTokenSecret"]!, PoolEnabled = true, ColorName = colorName, ParsedColor = parsedColor }; _usePool = false; Console.WriteLine($"[Pool] Single-key mode: {overrideSection} (Color: {_overrideKey.ParsedColor})"); return; } var root = config.AsEnumerable() .Where(kv => kv.Key.Contains(":ConsumerKey")) .Select(kv => kv.Key.Split(':')[0]) .Distinct() .ToList(); var autoAssignedColors = new List<(string sectionName, string color)>(); int keyNum = 1; foreach (var sectionName in root.OrderBy(s => s)) { var section = config.GetSection(sectionName); var poolEnabledStr = section["PoolEnabled"]; bool poolEnabled = !string.IsNullOrEmpty(poolEnabledStr) && bool.TryParse(poolEnabledStr, out var parsed) && parsed; if (poolEnabled) { var colorName = section["Color"] ?? string.Empty; var parsedColor = ParseColor(colorName); if (string.IsNullOrEmpty(colorName)) { colorName = GetNextAutoColor(); parsedColor = ParseColor(colorName); autoAssignedColors.Add((sectionName, colorName)); } _keys.Add(new ApiKeyConfig { SectionName = sectionName, KeyNumber = keyNum, ConsumerKey = section["ConsumerKey"]!, ConsumerSecret = section["ConsumerSecret"]!, OAuthToken = section["OAuthToken"]!, OAuthTokenSecret = section["OAuthTokenSecret"]!, PoolEnabled = true, ColorName = colorName, ParsedColor = parsedColor }); keyNum++; } } if (_keys.Count == 0) { var fallback = config.GetSection("TumblrApi"); if (fallback["ConsumerKey"] != null) { var colorName = fallback["Color"] ?? string.Empty; var parsedColor = ParseColor(colorName); if (string.IsNullOrEmpty(colorName)) { colorName = GetNextAutoColor(); parsedColor = ParseColor(colorName); autoAssignedColors.Add(("TumblrApi", colorName)); } _keys.Add(new ApiKeyConfig { SectionName = "TumblrApi", KeyNumber = 1, ConsumerKey = fallback["ConsumerKey"]!, ConsumerSecret = fallback["ConsumerSecret"]!, OAuthToken = fallback["OAuthToken"]!, OAuthTokenSecret = fallback["OAuthTokenSecret"]!, PoolEnabled = true, ColorName = colorName, ParsedColor = parsedColor }); Console.WriteLine("[Pool] No keys with PoolEnabled, falling back to TumblrApi"); } } if (autoAssignedColors.Count > 0) { PersistAutoAssignedColors(autoAssignedColors); } _usePool = true; LoadState(); var mapping = string.Join(", ", _keys.Select(k => $"Key#{k.KeyNumber}={k.SectionName} (Color: {k.ParsedColor})")); Console.WriteLine($"[Pool] Active keys: {mapping}"); } private static string GetNextAutoColor() { if (_nextAutoColorIndex < DefaultAutoColors.Length) return DefaultAutoColors[_nextAutoColorIndex++]; return "White"; } private static ConsoleColor ParseColor(string colorName) { if (string.IsNullOrEmpty(colorName)) return ConsoleColor.White; if (Enum.TryParse(colorName, out var color)) return color; Console.WriteLine($"[Pool] Warning: Invalid color '{colorName}', using White"); return ConsoleColor.White; } private static void PersistAutoAssignedColors(List<(string sectionName, string color)> assignments) { try { var json = File.ReadAllText(_configFilePath); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; using var stream = new MemoryStream(); using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); writer.WriteStartObject(); foreach (var prop in root.EnumerateObject()) { writer.WritePropertyName(prop.Name); if (prop.Value.ValueKind == JsonValueKind.Object) { writer.WriteStartObject(); foreach (var subProp in prop.Value.EnumerateObject()) { writer.WritePropertyName(subProp.Name); subProp.Value.WriteTo(writer); } var match = assignments.FirstOrDefault(a => a.sectionName == prop.Name); if (!string.IsNullOrEmpty(match.sectionName)) { writer.WriteString("Color", match.color); } writer.WriteEndObject(); } else { prop.Value.WriteTo(writer); } } writer.WriteEndObject(); writer.Flush(); File.WriteAllText(_configFilePath, Encoding.UTF8.GetString(stream.ToArray())); Console.WriteLine($"[Pool] Auto-assigned colors persisted to config: {string.Join(", ", assignments.Select(a => $"{a.sectionName}={a.color}"))}"); } catch (Exception ex) { Console.WriteLine($"[Pool] Warning: Could not persist auto-assigned colors to config: {ex.Message}"); } } private static void EnsureStateTableExists() { using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath); conn.Open(); using var cmd1 = new System.Data.SQLite.SQLiteCommand( "CREATE TABLE IF NOT EXISTS ApiKeyPoolState (KeyName TEXT PRIMARY KEY, RetryUntil INTEGER DEFAULT 0)", conn); cmd1.ExecuteNonQuery(); using var cmd2 = new System.Data.SQLite.SQLiteCommand( "CREATE TABLE IF NOT EXISTS ApiKeyPoolMeta (Id INTEGER PRIMARY KEY CHECK (Id = 1), LastIndex INTEGER DEFAULT 0)", conn); cmd2.ExecuteNonQuery(); } private static void LoadState() { using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath); conn.Open(); using var cmd = new System.Data.SQLite.SQLiteCommand("SELECT LastIndex FROM ApiKeyPoolMeta WHERE Id = 1", conn); var idx = cmd.ExecuteScalar(); if (idx != null) _currentIndex = Convert.ToInt32(idx); var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); foreach (var key in _keys) { using var readCmd = new System.Data.SQLite.SQLiteCommand( "SELECT RetryUntil FROM ApiKeyPoolState WHERE KeyName = @name", conn); readCmd.Parameters.AddWithValue("@name", key.SectionName); var retryUntil = readCmd.ExecuteScalar(); if (retryUntil != null) { var retryTs = Convert.ToInt64(retryUntil); if (retryTs > now) { var remaining = retryTs - now; var prevColor = Console.ForegroundColor; Console.ForegroundColor = key.ParsedColor; Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limited, clears in {remaining}s"); Console.ForegroundColor = prevColor; } else if (retryTs > 0) { var prevColor = Console.ForegroundColor; Console.ForegroundColor = key.ParsedColor; Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limit cleared on startup"); Console.ForegroundColor = prevColor; } } } } public static ApiKeyConfig GetCurrentKey() { ApiKeyConfig key; if (!_usePool || _overrideKey != null) key = _overrideKey!; else if (_keys.Count == 1) key = _keys[0]; else { int attempts = 0; while (attempts < _keys.Count) { key = _keys[_currentIndex % _keys.Count]; _currentIndex = (_currentIndex + 1) % _keys.Count; if (IsKeyAvailable(key)) { SaveState(); _activeKey = key; return key; } attempts++; } var earliest = _keys .Select(k => new { Key = k, RetryUntil = GetRetryUntil(k) }) .OrderBy(x => x.RetryUntil) .First(); Console.WriteLine($"[Pool] All keys rate-limited, using earliest: Key#{earliest.Key.KeyNumber} ({earliest.Key.SectionName}, clears in {Math.Max(0, earliest.RetryUntil - DateTimeOffset.UtcNow.ToUnixTimeSeconds())}s)"); key = earliest.Key; _currentIndex = (_keys.IndexOf(key) + 1) % _keys.Count; SaveState(); } _activeKey = key; return key; } private static bool IsKeyAvailable(ApiKeyConfig key) { var retryUntil = GetRetryUntil(key); return retryUntil <= DateTimeOffset.UtcNow.ToUnixTimeSeconds(); } private static long GetRetryUntil(ApiKeyConfig key) { using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath); conn.Open(); using var cmd = new System.Data.SQLite.SQLiteCommand( "SELECT RetryUntil FROM ApiKeyPoolState WHERE KeyName = @name", conn); cmd.Parameters.AddWithValue("@name", key.SectionName); var result = cmd.ExecuteScalar(); return result != null ? Convert.ToInt64(result) : 0; } public static void MarkRateLimited(ApiKeyConfig key, int retryInSeconds) { var retryUntil = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + retryInSeconds; using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath); conn.Open(); using var cmd = new System.Data.SQLite.SQLiteCommand( "INSERT OR REPLACE INTO ApiKeyPoolState (KeyName, RetryUntil) VALUES (@name, @until)", conn); cmd.Parameters.AddWithValue("@name", key.SectionName); cmd.Parameters.AddWithValue("@until", retryUntil); cmd.ExecuteNonQuery(); var prevColor = Console.ForegroundColor; Console.ForegroundColor = key.ParsedColor; Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limited, retry in {retryInSeconds}s (until {DateTimeOffset.FromUnixTimeSeconds(retryUntil).LocalDateTime:HH:mm:ss})"); Console.ForegroundColor = prevColor; } public static void MarkAvailable(ApiKeyConfig key) { using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath); conn.Open(); using var cmd = new System.Data.SQLite.SQLiteCommand( "INSERT OR REPLACE INTO ApiKeyPoolState (KeyName, RetryUntil) VALUES (@name, 0)", conn); cmd.Parameters.AddWithValue("@name", key.SectionName); cmd.ExecuteNonQuery(); var prevColor = Console.ForegroundColor; Console.ForegroundColor = key.ParsedColor; Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limit cleared"); Console.ForegroundColor = prevColor; } public static bool IsAllRateLimited(out int minRetrySeconds) { minRetrySeconds = 0; if (!_usePool || _overrideKey != null) return false; if (_keys.Count == 0) return false; var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); bool all = true; int min = int.MaxValue; foreach (var key in _keys) { var retryUntil = GetRetryUntil(key); if (retryUntil <= now) { all = false; break; } int remaining = (int)(retryUntil - now); if (remaining < min) min = remaining; } minRetrySeconds = all ? min : 0; return all; } public static void SleepUntilAnyAvailable(int refreshSeconds = 30) { if (refreshSeconds <= 0) refreshSeconds = 30; if (!IsAllRateLimited(out int minRetry) || minRetry <= 0) return; DateTime retryAt = DateTime.Now.AddSeconds(minRetry); int remaining = minRetry; while (remaining > 0) { Console.WriteLine("[Pool] All API keys rate-limited. Sleeping {0}s, until {1}", remaining, retryAt.ToString("T")); int sleepSeconds = Math.Min(refreshSeconds, remaining); Thread.Sleep(sleepSeconds * 1000); remaining -= sleepSeconds; if (remaining > 0 && IsAllRateLimited(out int refreshed) && refreshed > 0 && refreshed < remaining) { remaining = refreshed; retryAt = DateTime.Now.AddSeconds(remaining); } } } private static void SaveState() { using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath); conn.Open(); using var cmd = new System.Data.SQLite.SQLiteCommand( "INSERT OR REPLACE INTO ApiKeyPoolMeta (Id, LastIndex) VALUES (1, @idx)", conn); cmd.Parameters.AddWithValue("@idx", _currentIndex); cmd.ExecuteNonQuery(); } } internal class APIAccess { public static string Blog { get; set; } = string.Empty; public static void SetApiConfigSection(string sectionName) { if (!ApiKeyPool.IsPoolActive) Console.WriteLine($"Using API settings section: {sectionName}"); } private static RestClient BuildClient(ApiKeyConfig key, string url) { var client = new RestClient(url); var oAuth1 = OAuth1Authenticator.ForAccessToken( consumerKey: key.ConsumerKey, consumerSecret: key.ConsumerSecret, token: key.OAuthToken, tokenSecret: key.OAuthTokenSecret, OAuthSignatureMethod.HmacSha1); client.Authenticator = oAuth1; return client; } private static string FormatKeyLabel(ApiKeyConfig key) => $"[Key#{key.KeyNumber}]"; private static int GetRetryDelaySecondsFromHeaders(IEnumerable? headers) { if (headers == null) return 60; int retryInSeconds = 0; bool remainingIsZero = false; foreach (var header in headers) { string? headerName = header?.Name; string? headerValue = header?.Value?.ToString(); if (string.IsNullOrWhiteSpace(headerName) || string.IsNullOrWhiteSpace(headerValue)) continue; if (string.Equals(headerName, "Retry-After", StringComparison.OrdinalIgnoreCase)) { if (int.TryParse(headerValue, out int retrySecs)) { retryInSeconds = Math.Max(retryInSeconds, retrySecs); } else if (DateTimeOffset.TryParse(headerValue, out var retryAt)) { int secs = (int)Math.Max(0, (retryAt - DateTimeOffset.UtcNow).TotalSeconds); retryInSeconds = Math.Max(retryInSeconds, secs); } } if (headerName.IndexOf("X-RateLimit-Reset", StringComparison.OrdinalIgnoreCase) >= 0) { if (long.TryParse(headerValue, out long epoch)) { int secs = (int)Math.Max(0, epoch - DateTimeOffset.UtcNow.ToUnixTimeSeconds()); retryInSeconds = Math.Max(retryInSeconds, secs); } } if (headerName.Contains("Remaining", StringComparison.OrdinalIgnoreCase) && headerValue == "0") { remainingIsZero = true; } if (remainingIsZero && headerName.IndexOf("Reset", StringComparison.OrdinalIgnoreCase) >= 0) { if (int.TryParse(headerValue, out int resetValue)) { retryInSeconds = Math.Max(retryInSeconds, resetValue); } else if (long.TryParse(headerValue, out long resetEpoch)) { int secs = (int)Math.Max(0, resetEpoch - DateTimeOffset.UtcNow.ToUnixTimeSeconds()); retryInSeconds = Math.Max(retryInSeconds, secs); } } } return retryInSeconds > 0 ? retryInSeconds : 60; } public static async Task GrabNotes(ApiKeyConfig key, string blog, long ID, string? timestamp = null) { var URL = "https://api.tumblr.com/v2/blog/[0].tumblr.com/notes?id=[1]&mode=all"; URL = URL.Replace("[0]", blog).Replace("[1]", ID.ToString()); if (!string.IsNullOrEmpty(timestamp)) { URL += "&before_timestamp=" + timestamp; await Task.Delay(100); } using (var client = BuildClient(key, URL)) { var request = new RestRequest(URL, Method.Get); var response = await client.ExecuteAsync(request); var myJsonResponse = response.Content ?? string.Empty; Console.WriteLine($"{FormatKeyLabel(key)} {timestamp}\t{DateTime.Now}\t{DataAccess.UpdateAPICount()}"); var myDeserializedClass = new Root(); try { var deserializedResult = JsonConvert.DeserializeObject(myJsonResponse); if (deserializedResult != null) { myDeserializedClass = deserializedResult; myDeserializedClass.rawJson = myJsonResponse; if (myDeserializedClass.meta != null && myDeserializedClass.meta.status == 404) { myDeserializedClass.statusCode = "NotFound"; } bool metaIndicatesRateLimit = myDeserializedClass.meta != null && myDeserializedClass.meta.status == 429; bool metaMsgIndicatesRateLimit = myDeserializedClass.meta != null && !string.IsNullOrEmpty(myDeserializedClass.meta.msg) && myDeserializedClass.meta.msg.IndexOf("Too Many", StringComparison.OrdinalIgnoreCase) >= 0; if (metaIndicatesRateLimit || metaMsgIndicatesRateLimit || (response != null && (response.StatusDescription?.IndexOf("Too Many", StringComparison.OrdinalIgnoreCase) >= 0 || response.StatusCode == System.Net.HttpStatusCode.TooManyRequests))) { if (response?.Headers != null) { bool checkResetLocal = false; foreach (var header in response.Headers) { string? headerName = header?.Name; string? headerValue = header?.Value?.ToString(); if (string.IsNullOrEmpty(headerName) || string.IsNullOrEmpty(headerValue)) continue; if (string.Equals(headerName, "Retry-After", StringComparison.OrdinalIgnoreCase)) { if (int.TryParse(headerValue, out int retrySecs)) myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, retrySecs); else if (DateTimeOffset.TryParse(headerValue, out var dto)) myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, (int)Math.Max(0, (dto - DateTimeOffset.UtcNow).TotalSeconds)); } if (headerName.IndexOf("X-RateLimit-Reset", StringComparison.OrdinalIgnoreCase) >= 0 && long.TryParse(headerValue, out long epoch)) { var secs = (int)Math.Max(0, epoch - DateTimeOffset.UtcNow.ToUnixTimeSeconds()); myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, secs); } if (headerName.Contains("Remaining", StringComparison.OrdinalIgnoreCase) && headerValue == "0") checkResetLocal = true; if (checkResetLocal && headerName.IndexOf("Reset", StringComparison.OrdinalIgnoreCase) >= 0) { if (int.TryParse(headerValue, out int resetValue)) myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, resetValue); else if (long.TryParse(headerValue, out long epochVal)) myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, (int)Math.Max(0, epochVal - DateTimeOffset.UtcNow.ToUnixTimeSeconds())); } } } myDeserializedClass.statusCode = "TooManyRequests"; } } } catch (Exception ex) { Console.WriteLine($"Failed JSON: {myJsonResponse}"); Console.WriteLine(ex.ToString()); if (!response.IsSuccessful) { string? statusStr = null; try { statusStr = response != null ? response.StatusCode.ToString() : null; } catch { statusStr = null; } Console.WriteLine($"{statusStr}\t{response?.StatusDescription}"); if (!string.IsNullOrEmpty(statusStr)) myDeserializedClass.statusCode = statusStr; bool checkReset = false; if ((myDeserializedClass.statusCode != "NotFound" || myDeserializedClass.retryInSeconds > 0) && response.Headers != null) { bool foundRateLimitHeader = false; foreach (var header in response.Headers) { if (header.Name != null && header.Value != null) { Console.WriteLine($"{header.Name} - {header.Value}"); var headerValue = header.Value?.ToString(); if (!string.IsNullOrEmpty(headerValue)) { if (string.Equals(header.Name, "Retry-After", StringComparison.OrdinalIgnoreCase)) { if (int.TryParse(headerValue, out int retrySecs)) { if (myDeserializedClass.retryInSeconds < retrySecs) myDeserializedClass.retryInSeconds = retrySecs; } else if (DateTimeOffset.TryParse(headerValue, out DateTimeOffset dto)) { var secs = (int)Math.Max(0, (dto - DateTimeOffset.UtcNow).TotalSeconds); if (myDeserializedClass.retryInSeconds < secs) myDeserializedClass.retryInSeconds = secs; } } if (checkReset && header.Name.Contains("Reset", StringComparison.OrdinalIgnoreCase)) { if (int.TryParse(headerValue, out int resetValue)) { if (myDeserializedClass.retryInSeconds < resetValue) myDeserializedClass.retryInSeconds = resetValue; } else if (long.TryParse(headerValue, out long epochVal)) { var secs = (int)Math.Max(0, epochVal - DateTimeOffset.UtcNow.ToUnixTimeSeconds()); if (myDeserializedClass.retryInSeconds < secs) myDeserializedClass.retryInSeconds = secs; } } if (header.Name.IndexOf("X-RateLimit-Reset", StringComparison.OrdinalIgnoreCase) >= 0) { if (long.TryParse(headerValue, out long epoch)) { var secs = (int)Math.Max(0, epoch - DateTimeOffset.UtcNow.ToUnixTimeSeconds()); if (myDeserializedClass.retryInSeconds < secs) myDeserializedClass.retryInSeconds = secs; } foundRateLimitHeader = true; } } if (header.Name.Contains("Remaining", StringComparison.OrdinalIgnoreCase) && header.Value.ToString() == "0") checkReset = true; else checkReset = false; } if (foundRateLimitHeader || (response != null && response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)) { myDeserializedClass.statusCode = "TooManyRequests"; } } } } } return myDeserializedClass; } } public static async Task GrabPostWithReplies(ApiKeyConfig key, string blog, long postID, long timestamp) { var URL = "https://api.tumblr.com/v2/blog/[0].tumblr.com/notes?id=[1]&mode=conversation"; URL = URL.Replace("[0]", blog).Replace("[1]", postID.ToString()); if (timestamp > 0) { URL += $"&before_timestamp={timestamp}"; await Task.Delay(100); } using (var client = BuildClient(key, URL)) { var request = new RestRequest(URL, Method.Get); var response = await client.ExecuteAsync(request); var myJsonResponse = response.Content ?? string.Empty; Console.WriteLine($"[Reply API] {FormatKeyLabel(key)} {DateTime.Now}\t{DataAccess.UpdateAPICount()}"); var myDeserializedClass = new Root(); // Check if response is successful and contains JSON if (!response.IsSuccessful || string.IsNullOrEmpty(myJsonResponse)) { string? statusStr = null; try { statusStr = response != null ? response.StatusCode.ToString() : null; } catch { statusStr = null; } Console.WriteLine($"[Reply API] {statusStr}\t{response?.StatusDescription}"); if (!string.IsNullOrEmpty(statusStr)) myDeserializedClass.statusCode = statusStr; return myDeserializedClass; } // Check if response is HTML (error page) instead of JSON if (myJsonResponse.TrimStart().StartsWith("<")) { Console.WriteLine($"[Reply API] Received HTML response (likely error page): {response?.StatusCode}"); string? statusStr = null; try { statusStr = response != null ? response.StatusCode.ToString() : null; } catch { statusStr = null; } if (!string.IsNullOrEmpty(statusStr)) myDeserializedClass.statusCode = statusStr; return myDeserializedClass; } try { var deserializedResult = JsonConvert.DeserializeObject(myJsonResponse); if (deserializedResult != null) { myDeserializedClass = deserializedResult; myDeserializedClass.rawJson = myJsonResponse; bool metaIndicatesRateLimit = myDeserializedClass.meta != null && myDeserializedClass.meta.status == 429; bool metaMsgIndicatesRateLimit = myDeserializedClass.meta != null && !string.IsNullOrEmpty(myDeserializedClass.meta.msg) && myDeserializedClass.meta.msg.IndexOf("Too Many", StringComparison.OrdinalIgnoreCase) >= 0; if (metaIndicatesRateLimit || metaMsgIndicatesRateLimit || (response != null && (response.StatusDescription?.IndexOf("Too Many", StringComparison.OrdinalIgnoreCase) >= 0 || response.StatusCode == System.Net.HttpStatusCode.TooManyRequests))) { if (response?.Headers != null) { bool checkResetLocal = false; foreach (var header in response.Headers) { string? headerName = header?.Name; string? headerValue = header?.Value?.ToString(); if (string.IsNullOrEmpty(headerName) || string.IsNullOrEmpty(headerValue)) continue; if (string.Equals(headerName, "Retry-After", StringComparison.OrdinalIgnoreCase)) { if (int.TryParse(headerValue, out int retrySecs)) myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, retrySecs); else if (DateTimeOffset.TryParse(headerValue, out var dto)) myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, (int)Math.Max(0, (dto - DateTimeOffset.UtcNow).TotalSeconds)); } if (headerName.IndexOf("X-RateLimit-Reset", StringComparison.OrdinalIgnoreCase) >= 0 && long.TryParse(headerValue, out long epoch)) { var secs = (int)Math.Max(0, epoch - DateTimeOffset.UtcNow.ToUnixTimeSeconds()); myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, secs); } if (headerName.Contains("Remaining", StringComparison.OrdinalIgnoreCase) && headerValue == "0") checkResetLocal = true; if (checkResetLocal && headerName.IndexOf("Reset", StringComparison.OrdinalIgnoreCase) >= 0) { if (int.TryParse(headerValue, out int resetValue)) myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, resetValue); else if (long.TryParse(headerValue, out long epochVal)) myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, (int)Math.Max(0, epochVal - DateTimeOffset.UtcNow.ToUnixTimeSeconds())); } } } myDeserializedClass.statusCode = "TooManyRequests"; } } } catch (Exception ex) { Console.WriteLine($"[Reply API] Failed to parse JSON response: {ex.Message}"); Console.WriteLine($"[Reply API] Response content: {myJsonResponse.Substring(0, Math.Min(200, myJsonResponse.Length))}"); if (!response.IsSuccessful) { string? statusStr = null; try { statusStr = response != null ? response.StatusCode.ToString() : null; } catch { statusStr = null; } Console.WriteLine($"{statusStr}\t{response?.StatusDescription}"); if (!string.IsNullOrEmpty(statusStr)) myDeserializedClass.statusCode = statusStr; bool checkReset = false; if ((myDeserializedClass.statusCode != "NotFound" || myDeserializedClass.retryInSeconds > 0) && response.Headers != null) { bool foundRateLimitHeader = false; foreach (var header in response.Headers) { if (header.Name != null && header.Value != null) { Console.WriteLine($"{header.Name} - {header.Value}"); var headerValue = header.Value?.ToString(); if (!string.IsNullOrEmpty(headerValue)) { if (string.Equals(header.Name, "Retry-After", StringComparison.OrdinalIgnoreCase)) { if (int.TryParse(headerValue, out int retrySecs)) { if (myDeserializedClass.retryInSeconds < retrySecs) myDeserializedClass.retryInSeconds = retrySecs; } else if (DateTimeOffset.TryParse(headerValue, out DateTimeOffset dto)) { var secs = (int)Math.Max(0, (dto - DateTimeOffset.UtcNow).TotalSeconds); if (myDeserializedClass.retryInSeconds < secs) myDeserializedClass.retryInSeconds = secs; } } if (checkReset && header.Name.Contains("Reset", StringComparison.OrdinalIgnoreCase)) { if (int.TryParse(headerValue, out int resetValue)) { if (myDeserializedClass.retryInSeconds < resetValue) myDeserializedClass.retryInSeconds = resetValue; } else if (long.TryParse(headerValue, out long epochVal)) { var secs = (int)Math.Max(0, epochVal - DateTimeOffset.UtcNow.ToUnixTimeSeconds()); if (myDeserializedClass.retryInSeconds < secs) myDeserializedClass.retryInSeconds = secs; } } if (header.Name.IndexOf("X-RateLimit-Reset", StringComparison.OrdinalIgnoreCase) >= 0) { if (long.TryParse(headerValue, out long epoch)) { var secs = (int)Math.Max(0, epoch - DateTimeOffset.UtcNow.ToUnixTimeSeconds()); if (myDeserializedClass.retryInSeconds < secs) myDeserializedClass.retryInSeconds = secs; } foundRateLimitHeader = true; } } if (header.Name.Contains("Remaining", StringComparison.OrdinalIgnoreCase) && header.Value.ToString() == "0") checkReset = true; else checkReset = false; } if (foundRateLimitHeader || (response != null && response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)) { myDeserializedClass.statusCode = "TooManyRequests"; } } } } } return myDeserializedClass; } } public static async Task GrabLikes(ApiKeyConfig key, string blog, long beforeTimestamp = 0) { var URL = $"https://api.tumblr.com/v2/blog/{Uri.EscapeDataString(blog)}.tumblr.com/likes?npf=false&reblog_info=true"; if (beforeTimestamp > 0) { URL += $"&before={beforeTimestamp}"; } using (var client = BuildClient(key, URL)) { var request = new RestRequest(URL, Method.Get); var response = await client.ExecuteAsync(request); var myJsonResponse = response.Content ?? string.Empty; Console.WriteLine($"[Likes API] {FormatKeyLabel(key)} {DateTime.Now}\t{DataAccess.UpdateAPICount()}"); var myDeserializedClass = new LikesRoot(); // Check if response is successful and contains JSON if (!response.IsSuccessful || string.IsNullOrEmpty(myJsonResponse)) { string? statusStr = null; try { statusStr = response != null ? response.StatusCode.ToString() : null; } catch { statusStr = null; } Console.WriteLine($"[Likes API] {statusStr}\t{response?.StatusDescription}"); if (!string.IsNullOrEmpty(statusStr)) myDeserializedClass.statusCode = statusStr; if (response != null && response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) { myDeserializedClass.statusCode = "TooManyRequests"; myDeserializedClass.retryInSeconds = GetRetryDelaySecondsFromHeaders(response.Headers); } return myDeserializedClass; } if (myJsonResponse.TrimStart().StartsWith("<")) { Console.WriteLine($"[Likes API] Received HTML response (likely error page): {response?.StatusCode}"); myDeserializedClass.statusCode = response?.StatusCode.ToString(); if (response != null && response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) { myDeserializedClass.statusCode = "TooManyRequests"; myDeserializedClass.retryInSeconds = GetRetryDelaySecondsFromHeaders(response.Headers); } return myDeserializedClass; } try { var deserializedResult = JsonConvert.DeserializeObject(myJsonResponse); if (deserializedResult != null) { myDeserializedClass = deserializedResult; myDeserializedClass.rawJson = myJsonResponse; bool metaIndicatesRateLimit = myDeserializedClass.meta != null && myDeserializedClass.meta.status == 429; bool statusIndicatesRateLimit = response != null && response.StatusCode == System.Net.HttpStatusCode.TooManyRequests; if (metaIndicatesRateLimit || statusIndicatesRateLimit) { myDeserializedClass.statusCode = "TooManyRequests"; myDeserializedClass.retryInSeconds = GetRetryDelaySecondsFromHeaders(response?.Headers); } } } catch (Exception ex) { Console.WriteLine($"[Likes API] Failed to parse JSON response: {ex.Message}"); } return myDeserializedClass; } } } }