using System.Text; using Microsoft.Data.Sqlite; using OF_DL.Helpers; using Serilog; namespace OF_DL.Services; public class DbService(IConfigService configService) : IDbService { /// /// Creates or updates the per-user metadata database. /// /// The user folder path. public async Task CreateDb(string folder) { try { if (!Directory.Exists(folder + "/Metadata")) { Directory.CreateDirectory(folder + "/Metadata"); } string dbFilePath = $"{folder}/Metadata/user_data.db"; // connect to the new database file await using SqliteConnection connection = new($"Data Source={dbFilePath}"); // open the connection connection.Open(); // create the 'medias' table await using (SqliteCommand cmd = new( "CREATE TABLE IF NOT EXISTS medias (id INTEGER NOT NULL, media_id INTEGER, post_id INTEGER NOT NULL, link VARCHAR, directory VARCHAR, filename VARCHAR, size INTEGER, api_type VARCHAR, media_type VARCHAR, preview INTEGER, linked VARCHAR, downloaded INTEGER, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(media_id));", connection)) { await cmd.ExecuteNonQueryAsync(); } await EnsureCreatedAtColumnExists(connection, "medias"); // // Alter existing databases to create unique constraint on `medias` // await using (SqliteCommand cmd = new(@" PRAGMA foreign_keys=off; BEGIN TRANSACTION; ALTER TABLE medias RENAME TO old_medias; CREATE TABLE medias ( id INTEGER NOT NULL, media_id INTEGER, post_id INTEGER NOT NULL, link VARCHAR, directory VARCHAR, filename VARCHAR, size INTEGER, api_type VARCHAR, media_type VARCHAR, preview INTEGER, linked VARCHAR, downloaded INTEGER, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(media_id, api_type) ); INSERT INTO medias SELECT * FROM old_medias; DROP TABLE old_medias; COMMIT; PRAGMA foreign_keys=on;", connection)) { await cmd.ExecuteNonQueryAsync(); } // create the 'messages' table await using (SqliteCommand cmd = new( "CREATE TABLE IF NOT EXISTS messages (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, user_id INTEGER, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", connection)) { await cmd.ExecuteNonQueryAsync(); } // create the 'posts' table await using (SqliteCommand cmd = new( "CREATE TABLE IF NOT EXISTS posts (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", connection)) { await cmd.ExecuteNonQueryAsync(); } // create the 'stories' table await using (SqliteCommand cmd = new( "CREATE TABLE IF NOT EXISTS stories (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", connection)) { await cmd.ExecuteNonQueryAsync(); } // create the 'others' table await using (SqliteCommand cmd = new( "CREATE TABLE IF NOT EXISTS others (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", connection)) { await cmd.ExecuteNonQueryAsync(); } // create the 'products' table await using (SqliteCommand cmd = new( "CREATE TABLE IF NOT EXISTS products (id INTEGER NOT NULL, post_id INTEGER NOT NULL, text VARCHAR, price INTEGER, paid INTEGER, archived BOOLEAN, created_at TIMESTAMP, title VARCHAR, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(post_id));", connection)) { await cmd.ExecuteNonQueryAsync(); } // create the 'profiles' table await using (SqliteCommand cmd = new( "CREATE TABLE IF NOT EXISTS profiles (id INTEGER NOT NULL, user_id INTEGER NOT NULL, username VARCHAR NOT NULL, record_created_at TIMESTAMP, PRIMARY KEY(id), UNIQUE(username));", connection)) { await cmd.ExecuteNonQueryAsync(); } connection.Close(); } catch (Exception ex) { ExceptionLoggerHelper.LogException(ex); } } /// /// Creates or updates the global users database. /// /// The users to seed or update. public async Task CreateUsersDb(Dictionary users) { try { await using SqliteConnection connection = new($"Data Source={Directory.GetCurrentDirectory()}/users.db"); Log.Debug("Database data source: " + connection.DataSource); connection.Open(); await using (SqliteCommand cmd = new( "CREATE TABLE IF NOT EXISTS users (id INTEGER NOT NULL, user_id INTEGER NOT NULL, username VARCHAR NOT NULL, PRIMARY KEY(id), UNIQUE(username));", connection)) { await cmd.ExecuteNonQueryAsync(); } Log.Debug("Adding missing creators"); foreach (KeyValuePair user in users) { await using SqliteCommand checkCmd = new("SELECT user_id, username FROM users WHERE user_id = @userId;", connection); checkCmd.Parameters.AddWithValue("@userId", user.Value); await using SqliteDataReader reader = await checkCmd.ExecuteReaderAsync(); if (!reader.Read()) { await using SqliteCommand insertCmd = new("INSERT INTO users (user_id, username) VALUES (@userId, @username);", connection); insertCmd.Parameters.AddWithValue("@userId", user.Value); insertCmd.Parameters.AddWithValue("@username", user.Key); await insertCmd.ExecuteNonQueryAsync(); Log.Debug("Inserted new creator: " + user.Key); } else { Log.Debug("Creator " + user.Key + " already exists"); } } connection.Close(); } catch (Exception ex) { ExceptionLoggerHelper.LogException(ex); } } /// /// Ensures a username matches the stored user ID and migrates folders if needed. /// /// The user pair to validate. /// The expected user folder path. public async Task CheckUsername(KeyValuePair user, string path) { try { await using SqliteConnection connection = new($"Data Source={Directory.GetCurrentDirectory()}/users.db"); connection.Open(); await using (SqliteCommand checkCmd = new("SELECT user_id, username FROM users WHERE user_id = @userId;", connection)) { checkCmd.Parameters.AddWithValue("@userId", user.Value); await using (SqliteDataReader reader = await checkCmd.ExecuteReaderAsync()) { if (reader.Read()) { string storedUsername = reader.GetString(1); if (storedUsername != user.Key) { await using (SqliteCommand updateCmd = new("UPDATE users SET username = @newUsername WHERE user_id = @userId;", connection)) { updateCmd.Parameters.AddWithValue("@newUsername", user.Key); updateCmd.Parameters.AddWithValue("@userId", user.Value); await updateCmd.ExecuteNonQueryAsync(); } string oldPath = path.Replace(path.Split("/")[^1], storedUsername); if (Directory.Exists(oldPath)) { Directory.Move(path.Replace(path.Split("/")[^1], storedUsername), path); } } } } } connection.Close(); } catch (Exception ex) { ExceptionLoggerHelper.LogException(ex); } } /// /// Inserts a message record when it does not already exist. /// /// The user folder path. /// The message or post ID. /// The message text. /// The price string. /// Whether the message is paid. /// Whether the message is archived. /// The creation timestamp. /// The sender user ID. public async Task AddMessage(string folder, long postId, string messageText, string price, bool isPaid, bool isArchived, DateTime createdAt, long userId) { try { await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); connection.Open(); await EnsureCreatedAtColumnExists(connection, "messages"); await using SqliteCommand cmd = new("SELECT COUNT(*) FROM messages WHERE post_id=@post_id", connection); cmd.Parameters.AddWithValue("@post_id", postId); int count = Convert.ToInt32(await cmd.ExecuteScalarAsync()); if (count == 0) { // If the record doesn't exist, insert a new one await using SqliteCommand insertCmd = new( "INSERT INTO messages(post_id, text, price, paid, archived, created_at, user_id, record_created_at) VALUES(@post_id, @message_text, @price, @is_paid, @is_archived, @created_at, @user_id, @record_created_at)", connection); insertCmd.Parameters.AddWithValue("@post_id", postId); insertCmd.Parameters.AddWithValue("@message_text", messageText); insertCmd.Parameters.AddWithValue("@price", price); insertCmd.Parameters.AddWithValue("@is_paid", isPaid); insertCmd.Parameters.AddWithValue("@is_archived", isArchived); insertCmd.Parameters.AddWithValue("@created_at", createdAt); insertCmd.Parameters.AddWithValue("@user_id", userId); insertCmd.Parameters.AddWithValue("@record_created_at", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); await insertCmd.ExecuteNonQueryAsync(); } } catch (Exception ex) { ExceptionLoggerHelper.LogException(ex); } } /// /// Inserts a post record when it does not already exist. /// /// The user folder path. /// The post ID. /// The post text. /// The price string. /// Whether the post is paid. /// Whether the post is archived. /// The creation timestamp. public async Task AddPost(string folder, long postId, string messageText, string price, bool isPaid, bool isArchived, DateTime createdAt) { try { await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); connection.Open(); await EnsureCreatedAtColumnExists(connection, "posts"); await using SqliteCommand cmd = new("SELECT COUNT(*) FROM posts WHERE post_id=@post_id", connection); cmd.Parameters.AddWithValue("@post_id", postId); int count = Convert.ToInt32(await cmd.ExecuteScalarAsync()); if (count == 0) { // If the record doesn't exist, insert a new one await using SqliteCommand insertCmd = new( "INSERT INTO posts(post_id, text, price, paid, archived, created_at, record_created_at) VALUES(@post_id, @message_text, @price, @is_paid, @is_archived, @created_at, @record_created_at)", connection); insertCmd.Parameters.AddWithValue("@post_id", postId); insertCmd.Parameters.AddWithValue("@message_text", messageText); insertCmd.Parameters.AddWithValue("@price", price); insertCmd.Parameters.AddWithValue("@is_paid", isPaid); insertCmd.Parameters.AddWithValue("@is_archived", isArchived); insertCmd.Parameters.AddWithValue("@created_at", createdAt); insertCmd.Parameters.AddWithValue("@record_created_at", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); await insertCmd.ExecuteNonQueryAsync(); } } catch (Exception ex) { ExceptionLoggerHelper.LogException(ex); } } /// /// Inserts a story record when it does not already exist. /// /// The user folder path. /// The story ID. /// The story text. /// The price string. /// Whether the story is paid. /// Whether the story is archived. /// The creation timestamp. public async Task AddStory(string folder, long postId, string messageText, string price, bool isPaid, bool isArchived, DateTime createdAt) { try { await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); connection.Open(); await EnsureCreatedAtColumnExists(connection, "stories"); await using SqliteCommand cmd = new("SELECT COUNT(*) FROM stories WHERE post_id=@post_id", connection); cmd.Parameters.AddWithValue("@post_id", postId); int count = Convert.ToInt32(await cmd.ExecuteScalarAsync()); if (count == 0) { // If the record doesn't exist, insert a new one await using SqliteCommand insertCmd = new( "INSERT INTO stories(post_id, text, price, paid, archived, created_at, record_created_at) VALUES(@post_id, @message_text, @price, @is_paid, @is_archived, @created_at, @record_created_at)", connection); insertCmd.Parameters.AddWithValue("@post_id", postId); insertCmd.Parameters.AddWithValue("@message_text", messageText); insertCmd.Parameters.AddWithValue("@price", price); insertCmd.Parameters.AddWithValue("@is_paid", isPaid); insertCmd.Parameters.AddWithValue("@is_archived", isArchived); insertCmd.Parameters.AddWithValue("@created_at", createdAt); insertCmd.Parameters.AddWithValue("@record_created_at", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); await insertCmd.ExecuteNonQueryAsync(); } } catch (Exception ex) { ExceptionLoggerHelper.LogException(ex); } } /// /// Inserts a media record when it does not already exist. /// /// The user folder path. /// The media ID. /// The parent post ID. /// The media URL. /// The local directory path. /// The local filename. /// The media size in bytes. /// The API type label. /// The media type label. /// Whether the media is a preview. /// Whether the media is downloaded. /// The creation timestamp. public async Task AddMedia(string folder, long mediaId, long postId, string link, string? directory, string? filename, long? size, string apiType, string mediaType, bool preview, bool downloaded, DateTime? createdAt) { try { await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); connection.Open(); await EnsureCreatedAtColumnExists(connection, "medias"); StringBuilder sql = new("SELECT COUNT(*) FROM medias WHERE media_id=@media_id"); if (configService.CurrentConfig.DownloadDuplicatedMedia) { sql.Append(" and api_type=@api_type"); } await using SqliteCommand cmd = new(sql.ToString(), connection); cmd.Parameters.AddWithValue("@media_id", mediaId); cmd.Parameters.AddWithValue("@api_type", apiType); int count = Convert.ToInt32(cmd.ExecuteScalar()); if (count == 0) { // If the record doesn't exist, insert a new one await using SqliteCommand insertCmd = new( $"INSERT INTO medias(media_id, post_id, link, directory, filename, size, api_type, media_type, preview, downloaded, created_at, record_created_at) VALUES({mediaId}, {postId}, '{link}', '{directory ?? "NULL"}', '{filename ?? "NULL"}', {size?.ToString() ?? "NULL"}, '{apiType}', '{mediaType}', {Convert.ToInt32(preview)}, {Convert.ToInt32(downloaded)}, '{createdAt?.ToString("yyyy-MM-dd HH:mm:ss")}', '{DateTime.Now:yyyy-MM-dd HH:mm:ss}')", connection); await insertCmd.ExecuteNonQueryAsync(); } } catch (Exception ex) { ExceptionLoggerHelper.LogException(ex); } } /// /// Checks whether the media has been marked as downloaded. /// /// The user folder path. /// The media ID. /// The API type label. /// True when the media is marked as downloaded. public async Task CheckDownloaded(string folder, long mediaId, string apiType) { try { await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); StringBuilder sql = new("SELECT downloaded FROM medias WHERE media_id=@media_id"); if (configService.CurrentConfig.DownloadDuplicatedMedia) { sql.Append(" and api_type=@api_type"); } connection.Open(); await using SqliteCommand cmd = new(sql.ToString(), connection); cmd.Parameters.AddWithValue("@media_id", mediaId); cmd.Parameters.AddWithValue("@api_type", apiType); bool downloaded = Convert.ToBoolean(await cmd.ExecuteScalarAsync()); return downloaded; } catch (Exception ex) { ExceptionLoggerHelper.LogException(ex); } return false; } /// /// Updates the media record with local file details. /// /// The user folder path. /// The media ID. /// The API type label. /// The local directory path. /// The local filename. /// The file size in bytes. /// Whether the media is downloaded. /// The creation timestamp. public async Task UpdateMedia(string folder, long mediaId, string apiType, string directory, string filename, long size, bool downloaded, DateTime createdAt) { await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); connection.Open(); // Construct the update command StringBuilder sql = new( "UPDATE medias SET directory=@directory, filename=@filename, size=@size, downloaded=@downloaded, created_at=@created_at WHERE media_id=@media_id"); if (configService.CurrentConfig.DownloadDuplicatedMedia) { sql.Append(" and api_type=@api_type"); } // Create a new command object await using SqliteCommand command = new(sql.ToString(), connection); // Add parameters to the command object command.Parameters.AddWithValue("@directory", directory); command.Parameters.AddWithValue("@filename", filename); command.Parameters.AddWithValue("@size", size); command.Parameters.AddWithValue("@downloaded", downloaded ? 1 : 0); command.Parameters.AddWithValue("@created_at", createdAt); command.Parameters.AddWithValue("@media_id", mediaId); command.Parameters.AddWithValue("@api_type", apiType); // Execute the command await command.ExecuteNonQueryAsync(); } /// /// Returns the stored size for a media record. /// /// The user folder path. /// The media ID. /// The API type label. /// The stored file size. public async Task GetStoredFileSize(string folder, long mediaId, string apiType) { await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); connection.Open(); await using SqliteCommand cmd = new("SELECT size FROM medias WHERE media_id=@media_id and api_type=@api_type", connection); cmd.Parameters.AddWithValue("@media_id", mediaId); cmd.Parameters.AddWithValue("@api_type", apiType); long size = Convert.ToInt64(await cmd.ExecuteScalarAsync()); return size; } /// /// Returns the most recent post date based on downloaded and pending media. /// /// The user folder path. /// The most recent post date if available. public async Task GetMostRecentPostDate(string folder) { DateTime? mostRecentDate = null; await using SqliteConnection connection = new($"Data Source={folder}/Metadata/user_data.db"); connection.Open(); await using SqliteCommand cmd = new(@" SELECT MIN(created_at) AS created_at FROM ( SELECT MAX(P.created_at) AS created_at FROM posts AS P LEFT OUTER JOIN medias AS m ON P.post_id = m.post_id AND m.downloaded = 1 UNION SELECT MIN(P.created_at) AS created_at FROM posts AS P INNER JOIN medias AS m ON P.post_id = m.post_id WHERE m.downloaded = 0 )", connection); object? scalarValue = await cmd.ExecuteScalarAsync(); if (scalarValue != null && scalarValue != DBNull.Value) { mostRecentDate = Convert.ToDateTime(scalarValue); } return mostRecentDate; } private async Task EnsureCreatedAtColumnExists(SqliteConnection connection, string tableName) { await using SqliteCommand cmd = new($"PRAGMA table_info({tableName});", connection); await using SqliteDataReader reader = await cmd.ExecuteReaderAsync(); bool columnExists = false; while (await reader.ReadAsync()) { if (reader["name"].ToString() == "record_created_at") { columnExists = true; break; } } if (!columnExists) { await using SqliteCommand alterCmd = new($"ALTER TABLE {tableName} ADD COLUMN record_created_at TIMESTAMP;", connection); await alterCmd.ExecuteNonQueryAsync(); } } }