OF-DL/OF DL.Core/Services/DbService.cs

592 lines
26 KiB
C#

using System.Text;
using Microsoft.Data.Sqlite;
using OF_DL.Helpers;
using Serilog;
namespace OF_DL.Services;
public class DbService(IConfigService configService) : IDbService
{
/// <summary>
/// Creates or updates the per-user metadata database.
/// </summary>
/// <param name="folder">The user folder path.</param>
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);
}
}
/// <summary>
/// Creates or updates the global users database.
/// </summary>
/// <param name="users">The users to seed or update.</param>
public async Task CreateUsersDb(Dictionary<string, long> 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<string, long> 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);
}
}
/// <summary>
/// Ensures a username matches the stored user ID and migrates folders if needed.
/// </summary>
/// <param name="user">The user pair to validate.</param>
/// <param name="path">The expected user folder path.</param>
public async Task CheckUsername(KeyValuePair<string, long> 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);
}
}
/// <summary>
/// Inserts a message record when it does not already exist.
/// </summary>
/// <param name="folder">The user folder path.</param>
/// <param name="postId">The message or post ID.</param>
/// <param name="messageText">The message text.</param>
/// <param name="price">The price string.</param>
/// <param name="isPaid">Whether the message is paid.</param>
/// <param name="isArchived">Whether the message is archived.</param>
/// <param name="createdAt">The creation timestamp.</param>
/// <param name="userId">The sender user ID.</param>
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);
}
}
/// <summary>
/// Inserts a post record when it does not already exist.
/// </summary>
/// <param name="folder">The user folder path.</param>
/// <param name="postId">The post ID.</param>
/// <param name="messageText">The post text.</param>
/// <param name="price">The price string.</param>
/// <param name="isPaid">Whether the post is paid.</param>
/// <param name="isArchived">Whether the post is archived.</param>
/// <param name="createdAt">The creation timestamp.</param>
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);
}
}
/// <summary>
/// Inserts a story record when it does not already exist.
/// </summary>
/// <param name="folder">The user folder path.</param>
/// <param name="postId">The story ID.</param>
/// <param name="messageText">The story text.</param>
/// <param name="price">The price string.</param>
/// <param name="isPaid">Whether the story is paid.</param>
/// <param name="isArchived">Whether the story is archived.</param>
/// <param name="createdAt">The creation timestamp.</param>
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);
}
}
/// <summary>
/// Inserts a media record when it does not already exist.
/// </summary>
/// <param name="folder">The user folder path.</param>
/// <param name="mediaId">The media ID.</param>
/// <param name="postId">The parent post ID.</param>
/// <param name="link">The media URL.</param>
/// <param name="directory">The local directory path.</param>
/// <param name="filename">The local filename.</param>
/// <param name="size">The media size in bytes.</param>
/// <param name="apiType">The API type label.</param>
/// <param name="mediaType">The media type label.</param>
/// <param name="preview">Whether the media is a preview.</param>
/// <param name="downloaded">Whether the media is downloaded.</param>
/// <param name="createdAt">The creation timestamp.</param>
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);
}
}
/// <summary>
/// Checks whether the media has been marked as downloaded.
/// </summary>
/// <param name="folder">The user folder path.</param>
/// <param name="mediaId">The media ID.</param>
/// <param name="apiType">The API type label.</param>
/// <returns>True when the media is marked as downloaded.</returns>
public async Task<bool> 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;
}
/// <summary>
/// Updates the media record with local file details.
/// </summary>
/// <param name="folder">The user folder path.</param>
/// <param name="mediaId">The media ID.</param>
/// <param name="apiType">The API type label.</param>
/// <param name="directory">The local directory path.</param>
/// <param name="filename">The local filename.</param>
/// <param name="size">The file size in bytes.</param>
/// <param name="downloaded">Whether the media is downloaded.</param>
/// <param name="createdAt">The creation timestamp.</param>
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();
}
/// <summary>
/// Returns the stored size for a media record.
/// </summary>
/// <param name="folder">The user folder path.</param>
/// <param name="mediaId">The media ID.</param>
/// <param name="apiType">The API type label.</param>
/// <returns>The stored file size.</returns>
public async Task<long> 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;
}
/// <summary>
/// Returns the most recent post date based on downloaded and pending media.
/// </summary>
/// <param name="folder">The user folder path.</param>
/// <returns>The most recent post date if available.</returns>
public async Task<DateTime?> 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")
{
continue;
}
columnExists = true;
break;
}
if (!columnExists)
{
await using SqliteCommand alterCmd = new($"ALTER TABLE {tableName} ADD COLUMN record_created_at TIMESTAMP;",
connection);
await alterCmd.ExecuteNonQueryAsync();
}
}
}