using Microsoft.Data.Sqlite; using Shared.Services; using System; using System.Collections.Generic; using System.Data; using System.Globalization; using System.IO; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Tasks; namespace DatabaseEditor; public sealed class SaveRecordRequest { public string database { get; set; } public long? id { get; set; } public string user { get; set; } public string card { get; set; } public string item { get; set; } public string data { get; set; } } public sealed class DeleteRecordRequest { public string database { get; set; } public long id { get; set; } } public sealed class BackupRequest { public string database { get; set; } } public sealed class RestoreBackupRequest { public string database { get; set; } public string file { get; set; } } public sealed class RenameUserRequest { public string oldUser { get; set; } public string newUser { get; set; } } public sealed class RenameUserResult { public string oldUser { get; set; } public string newUser { get; set; } public int syncRecords { get; set; } public int timecodeRecords { get; set; } } public sealed class DeleteUserRequest { public string user { get; set; } } public sealed class DeleteUserResult { public string user { get; set; } public int syncRecords { get; set; } public int timecodeRecords { get; set; } } public sealed class SaveSyncItemRequest { public long recordId { get; set; } public string cardId { get; set; } public string[] categories { get; set; } } public sealed class DeleteSyncItemRequest { public long recordId { get; set; } public string cardId { get; set; } } public sealed class DatabaseSummary { public string database { get; set; } public string title { get; set; } public string file { get; set; } public bool available { get; set; } public long records { get; set; } public long bytes { get; set; } public string updated { get; set; } } public sealed class DatabaseUserOption { public string user { get; set; } public long records { get; set; } } public sealed class DatabaseRecord { public long id { get; set; } public string user { get; set; } public string card { get; set; } public string item { get; set; } public string data { get; set; } public string preview { get; set; } public long dataLength { get; set; } public string updated { get; set; } public string title { get; set; } public string poster { get; set; } public string year { get; set; } public string mediaType { get; set; } public int? season { get; set; } public int? episode { get; set; } public double? position { get; set; } public double? duration { get; set; } public double? percent { get; set; } } public sealed class SyncUserItem { public string cardId { get; set; } public string title { get; set; } public string poster { get; set; } public string year { get; set; } public string mediaType { get; set; } public List categories { get; set; } public int order { get; set; } public Dictionary categoryOrder { get; set; } } public sealed class SyncUserDetails { public long id { get; set; } public string user { get; set; } public string updated { get; set; } public int total { get; set; } public List items { get; set; } } public sealed class DatabaseBackupResult { public string database { get; set; } public string path { get; set; } } public sealed class DatabaseBackupFile { public string database { get; set; } public string file { get; set; } public string path { get; set; } public long bytes { get; set; } public string created { get; set; } } public sealed class DatabaseRestoreResult { public string database { get; set; } public string restoredFrom { get; set; } public string safetyBackup { get; set; } } public sealed class RecordsPage { public string database { get; set; } public int page { get; set; } public int pageSize { get; set; } public long total { get; set; } public int pages { get; set; } public List records { get; set; } } public sealed class DatabaseEditorValidationException : Exception { public DatabaseEditorValidationException(string message) : base(message) { } } public sealed class DatabaseEditorConflictException : Exception { public DatabaseEditorConflictException(string message) : base(message) { } } public sealed class DatabaseEditorBusyException : Exception { public DatabaseEditorBusyException(string message) : base(message) { } } static class DatabaseStore { const int MaxDataBytes = 8 * 1024 * 1024; const int MaxKeyLength = 512; public static readonly string[] SyncCategories = { "history", "like", "watch", "wath", "book", "look", "viewed", "scheduled", "continued", "thrown" }; public static readonly string[] SyncEditorCategories = { "book", "like", "wath", "history", "look", "viewed", "scheduled", "continued", "thrown" }; public static readonly string[] SyncStatusCategories = { "look", "viewed", "scheduled", "continued", "thrown" }; sealed class DatabaseSpec { public string key; public string title; public string path; public string table; public string semaphore; public bool timecode; /// Колонка, по которой сортируем и берём MAX. public string updatedColumn = "updated"; /// Как показать её строкой: у TimeCode это мс, а не ISO-текст. public Func updatedText = expression => expression; } /// /// TimeCode хранит таймкод типизированными колонками, а редактор правит road-объект Lampa — /// тот же контракт, что у легаси `/timecode/all`. Собираем его на стороне SQLite. /// const string TimeCodeRoad = "json_object('duration', duration, 'time', position, 'percent', percent, 'profile', profile, 'updated', watched_at)"; static string TimeCodeStamp(string expression) => $"strftime('%Y-%m-%dT%H:%M:%SZ', {expression} / 1000, 'unixepoch')"; /// Обратная операция к : road из редактора → колонки. const string TimeCodeColumns = "position, duration, percent, profile, watched_at"; const string TimeCodeValues = "COALESCE(json_extract(@data, '$.time'), 0), " + "COALESCE(json_extract(@data, '$.duration'), 0), " + "COALESCE(json_extract(@data, '$.percent'), 0), " + "COALESCE(json_extract(@data, '$.profile'), 0), " + "COALESCE(json_extract(@data, '$.updated'), 0)"; const string TimeCodeAssignments = "position = COALESCE(json_extract(@data, '$.time'), 0), " + "duration = COALESCE(json_extract(@data, '$.duration'), 0), " + "percent = COALESCE(json_extract(@data, '$.percent'), 0), " + "profile = COALESCE(json_extract(@data, '$.profile'), 0), " + "watched_at = COALESCE(json_extract(@data, '$.updated'), 0)"; sealed class MediaMetadata { public string title; public string poster; public string year; public string mediaType; public List hashTitles; public int seasons; public int episodes; } static readonly DatabaseSpec Sync = new() { key = "sync", title = "Sync / закладки", path = Path.Combine("database", "Sync.sql"), table = "bookmarks", semaphore = "Sync" }; static readonly DatabaseSpec TimeCode = new() { key = "timecode", title = "TimeCode / позиции", path = Path.Combine("database", "TimeCode.sql"), table = "timecodes", semaphore = "TimeCode", timecode = true, updatedColumn = "updated_at", updatedText = TimeCodeStamp }; public static async Task> GetSummaryAsync() { var result = new List(2); result.Add(await ReadSummaryAsync(TimeCode)); result.Add(await ReadSummaryAsync(Sync)); return result; } public static async Task> GetUsersAsync(string database) { DatabaseSpec spec = GetSpec(database); await using var connection = await OpenAsync(spec); await using var command = connection.CreateCommand(); command.CommandText = $"SELECT user, COUNT(*) FROM {spec.table} WHERE user IS NOT NULL AND TRIM(user) <> '' GROUP BY user COLLATE NOCASE ORDER BY user COLLATE NOCASE;"; var users = new List(); await using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { users.Add(new DatabaseUserOption { user = ReadString(reader, 0), records = reader.IsDBNull(1) ? 0 : reader.GetInt64(1) }); } return users; } public static async Task GetRecordsAsync(string database, string query, int page, int pageSize, string user = null) { DatabaseSpec spec = GetSpec(database); page = Math.Max(1, page); pageSize = pageSize is 25 or 50 or 100 ? pageSize : 25; query = (query ?? string.Empty).Trim(); if (query.Length > 256) throw new DatabaseEditorValidationException("search_too_long"); user = (user ?? string.Empty).Trim(); if (user.Length > MaxKeyLength) throw new DatabaseEditorValidationException("key_too_long"); await using var connection = await OpenAsync(spec); string where = BuildWhere(spec, query, user, out string searchValue, out string selectedUser); long total; await using (var count = connection.CreateCommand()) { count.CommandText = $"SELECT COUNT(*) FROM {spec.table}{where};"; AddFilters(count, searchValue, selectedUser); total = Convert.ToInt64(await count.ExecuteScalarAsync()); } int pages = Math.Max(1, (int)Math.Ceiling(total / (double)pageSize)); page = Math.Min(page, pages); int offset = (page - 1) * pageSize; var records = new List(pageSize); await using (var command = connection.CreateCommand()) { command.CommandText = spec.timecode ? $"SELECT Id, user, card, item, length({TimeCodeRoad}), {TimeCodeRoad}, {spec.updatedText(spec.updatedColumn)} FROM {spec.table}{where} ORDER BY {spec.updatedColumn} DESC, Id DESC LIMIT @limit OFFSET @offset;" : $"SELECT Id, user, length(data), substr(data, 1, 420), {spec.updatedText(spec.updatedColumn)} FROM {spec.table}{where} ORDER BY {spec.updatedColumn} DESC, Id DESC LIMIT @limit OFFSET @offset;"; AddFilters(command, searchValue, selectedUser); command.Parameters.AddWithValue("@limit", pageSize); command.Parameters.AddWithValue("@offset", offset); await using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { int index = 0; var record = new DatabaseRecord { id = reader.GetInt64(index++), user = ReadString(reader, index++) }; if (spec.timecode) { record.card = ReadString(reader, index++); record.item = ReadString(reader, index++); } record.dataLength = reader.IsDBNull(index) ? 0 : reader.GetInt64(index); index++; string dataValue = ReadString(reader, index++); record.preview = BuildPreview(dataValue); record.updated = ReadString(reader, index); if (spec.timecode) ReadPlaybackData(record, dataValue); records.Add(record); } } if (spec.timecode && records.Count > 0) await EnrichTimeCodeRecordsAsync(records); return new RecordsPage { database = spec.key, page = page, pageSize = pageSize, total = total, pages = pages, records = records }; } public static async Task GetRecordAsync(string database, long id) { DatabaseSpec spec = GetSpec(database); if (id <= 0) throw new DatabaseEditorValidationException("invalid_id"); await using var connection = await OpenAsync(spec); await using var command = connection.CreateCommand(); command.CommandText = spec.timecode ? $"SELECT Id, user, card, item, {TimeCodeRoad}, {spec.updatedText(spec.updatedColumn)} FROM {spec.table} WHERE Id = @id LIMIT 1;" : $"SELECT Id, user, data, {spec.updatedText(spec.updatedColumn)} FROM {spec.table} WHERE Id = @id LIMIT 1;"; command.Parameters.AddWithValue("@id", id); await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow); if (!await reader.ReadAsync()) return null; int index = 0; var record = new DatabaseRecord { id = reader.GetInt64(index++), user = ReadString(reader, index++) }; if (spec.timecode) { record.card = ReadString(reader, index++); record.item = ReadString(reader, index++); } record.data = ReadString(reader, index++); record.dataLength = Encoding.UTF8.GetByteCount(record.data ?? string.Empty); record.updated = ReadString(reader, index); return record; } public static async Task GetSyncUserAsync(long id) { if (id <= 0) throw new DatabaseEditorValidationException("invalid_id"); await using var connection = await OpenAsync(Sync); await using var command = connection.CreateCommand(); command.CommandText = "SELECT Id, user, data, updated FROM bookmarks WHERE Id = @id LIMIT 1;"; command.Parameters.AddWithValue("@id", id); await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow); if (!await reader.ReadAsync()) return null; string data = ReadString(reader, 2); var details = BuildSyncUserDetails(reader.GetInt64(0), ReadString(reader, 1), ReadString(reader, 3), data); return details; } public static async Task> SaveSyncItemAsync(SaveSyncItemRequest request) { if (request == null || request.recordId <= 0) throw new DatabaseEditorValidationException("invalid_id"); string cardId = ValidateKey(request.cardId, "card_required"); var selected = new HashSet(request.categories ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); foreach (string category in selected) { if (Array.IndexOf(SyncEditorCategories, category) < 0) throw new DatabaseEditorValidationException("unknown_category"); } int selectedStatuses = 0; foreach (string category in SyncStatusCategories) { if (selected.Contains(category)) selectedStatuses++; } if (selectedStatuses > 1) throw new DatabaseEditorValidationException("multiple_statuses"); await UpdateSyncJsonAsync(request.recordId, root => { JsonObject card = FindSyncCard(root, cardId); if (card == null) throw new DatabaseEditorValidationException("card_not_found"); foreach (string category in SyncCategories) { JsonArray array = EnsureArray(root, category); bool isSelected = selected.Contains(category); bool isPresent = ContainsCardId(array, cardId); if (isSelected && !isPresent) array.Insert(0, CreateCardIdNode(cardId)); else if (!isSelected && isPresent) RemoveCardId(array, cardId); } }); Serilog.Log.Information("DatabaseEditor updated Sync card {CardId} in record {RecordId}", cardId, request.recordId); var result = new List(); foreach (string category in SyncEditorCategories) { if (selected.Contains(category)) result.Add(category); } return result; } public static async Task DeleteSyncItemAsync(DeleteSyncItemRequest request) { if (request == null || request.recordId <= 0) throw new DatabaseEditorValidationException("invalid_id"); string cardId = ValidateKey(request.cardId, "card_required"); bool removed = false; await UpdateSyncJsonAsync(request.recordId, root => { if (root["card"] is JsonArray cards) { for (int index = cards.Count - 1; index >= 0; index--) { if (cards[index] is JsonObject card && string.Equals(NodeText(card["id"]), cardId, StringComparison.Ordinal)) { cards.RemoveAt(index); removed = true; } } } foreach (string category in SyncCategories) { if (root[category] is JsonArray array) removed |= RemoveCardId(array, cardId); } }); if (removed) Serilog.Log.Information("DatabaseEditor deleted Sync card {CardId} from record {RecordId}", cardId, request.recordId); return removed; } public static async Task SaveAsync(SaveRecordRequest request) { if (request == null) throw new DatabaseEditorValidationException("request_required"); DatabaseSpec spec = GetSpec(request.database); string user = ValidateKey(request.user, "user_required"); string card = spec.timecode ? ValidateKey(request.card, "card_required") : null; string item = spec.timecode ? ValidateKey(request.item, "item_required") : null; string data = NormalizeJson(request.data); long id = request.id.GetValueOrDefault(); if (id < 0) throw new DatabaseEditorValidationException("invalid_id"); var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20)); bool acquired = await semaphore.WaitAsync(); if (!acquired) throw new DatabaseEditorBusyException("database_busy"); try { await using var connection = await OpenAsync(spec); using var transaction = connection.BeginTransaction(); string updated = DateTime.UtcNow.ToString("O"); // Правка из админки обязана поднять курсор выше всех строк пользователя, иначе // клиенты её не увидят: дельты они спрашивают по `updated_at > since`. string stamp = $"MAX(strftime('%s', 'now') * 1000, (SELECT COALESCE(MAX(updated_at), 0) + 1 FROM {spec.table} WHERE user = @user))"; await using var command = connection.CreateCommand(); command.Transaction = transaction; if (id == 0) { command.CommandText = spec.timecode ? $"INSERT INTO {spec.table} (user, card, item, {TimeCodeColumns}, updated_at) VALUES (@user, @card, @item, {TimeCodeValues}, {stamp}); SELECT last_insert_rowid();" : $"INSERT INTO {spec.table} (user, data, updated) VALUES (@user, @data, @updated); SELECT last_insert_rowid();"; } else { command.CommandText = spec.timecode ? $"UPDATE {spec.table} SET user = @user, card = @card, item = @item, {TimeCodeAssignments}, updated_at = {stamp} WHERE Id = @id;" : $"UPDATE {spec.table} SET user = @user, data = @data, updated = @updated WHERE Id = @id;"; command.Parameters.AddWithValue("@id", id); } command.Parameters.AddWithValue("@user", user); if (spec.timecode) { command.Parameters.AddWithValue("@card", card); command.Parameters.AddWithValue("@item", item); } command.Parameters.AddWithValue("@data", data); if (!spec.timecode) command.Parameters.AddWithValue("@updated", updated); if (id == 0) id = Convert.ToInt64(await command.ExecuteScalarAsync()); else if (await command.ExecuteNonQueryAsync() == 0) throw new DatabaseEditorValidationException("record_not_found"); transaction.Commit(); Serilog.Log.Information("DatabaseEditor saved {Database} record {RecordId}", spec.key, id); } catch (SqliteException ex) when (ex.SqliteErrorCode == 19) { throw new DatabaseEditorConflictException("duplicate_record_key"); } finally { semaphore.Release(); } return await GetRecordAsync(spec.key, id); } public static async Task DeleteAsync(string database, long id) { DatabaseSpec spec = GetSpec(database); if (id <= 0) throw new DatabaseEditorValidationException("invalid_id"); var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20)); bool acquired = await semaphore.WaitAsync(); if (!acquired) throw new DatabaseEditorBusyException("database_busy"); try { await using var connection = await OpenAsync(spec); await using var command = connection.CreateCommand(); command.CommandText = $"DELETE FROM {spec.table} WHERE Id = @id;"; command.Parameters.AddWithValue("@id", id); bool deleted = await command.ExecuteNonQueryAsync() > 0; if (deleted) Serilog.Log.Information("DatabaseEditor deleted {Database} record {RecordId}", spec.key, id); return deleted; } finally { semaphore.Release(); } } public static async Task RenameUserAsync(RenameUserRequest request) { if (request == null) throw new DatabaseEditorValidationException("request_required"); string oldUser = ValidateKey(request.oldUser, "old_user_required"); string newUser = ValidateKey(request.newUser, "new_user_required"); if (string.Equals(oldUser, newUser, StringComparison.Ordinal)) throw new DatabaseEditorValidationException("user_name_unchanged"); if (!File.Exists(Sync.path) || !File.Exists(TimeCode.path)) throw new DatabaseEditorValidationException("database_not_found"); var syncSemaphore = new SemaphorManager(Sync.semaphore, TimeSpan.FromSeconds(20)); var timecodeSemaphore = new SemaphorManager(TimeCode.semaphore, TimeSpan.FromSeconds(20)); bool syncAcquired = await syncSemaphore.WaitAsync(); if (!syncAcquired) throw new DatabaseEditorBusyException("database_busy"); bool timecodeAcquired = false; try { timecodeAcquired = await timecodeSemaphore.WaitAsync(); if (!timecodeAcquired) throw new DatabaseEditorBusyException("database_busy"); await using var connection = await OpenAsync(TimeCode, pooling: false); await using (var attach = connection.CreateCommand()) { attach.CommandText = "ATTACH DATABASE @syncPath AS syncdb;"; attach.Parameters.AddWithValue("@syncPath", Path.GetFullPath(Sync.path)); await attach.ExecuteNonQueryAsync(); } using var transaction = connection.BeginTransaction(); await EnsureRenameTargetAvailableAsync(connection, transaction, "main.timecodes", oldUser, newUser); await EnsureRenameTargetAvailableAsync(connection, transaction, "syncdb.bookmarks", oldUser, newUser); int timecodeRecords = await RenameUserRowsAsync(connection, transaction, "main.timecodes", oldUser, newUser); int syncRecords = await RenameUserRowsAsync(connection, transaction, "syncdb.bookmarks", oldUser, newUser); if (timecodeRecords == 0 && syncRecords == 0) throw new DatabaseEditorValidationException("user_not_found"); transaction.Commit(); Serilog.Log.Information("DatabaseEditor renamed user {OldUser} to {NewUser}: {SyncRecords} Sync, {TimecodeRecords} TimeCode records", oldUser, newUser, syncRecords, timecodeRecords); return new RenameUserResult { oldUser = oldUser, newUser = newUser, syncRecords = syncRecords, timecodeRecords = timecodeRecords }; } catch (SqliteException ex) when (ex.SqliteErrorCode == 19) { throw new DatabaseEditorConflictException("rename_user_conflict"); } finally { if (timecodeAcquired) timecodeSemaphore.Release(); syncSemaphore.Release(); } } static async Task EnsureRenameTargetAvailableAsync(SqliteConnection connection, SqliteTransaction transaction, string table, string oldUser, string newUser) { await using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = $"SELECT 1 FROM {table} WHERE user = @newUser COLLATE NOCASE AND user <> @oldUser COLLATE NOCASE LIMIT 1;"; command.Parameters.AddWithValue("@oldUser", oldUser); command.Parameters.AddWithValue("@newUser", newUser); if (await command.ExecuteScalarAsync() != null) throw new DatabaseEditorConflictException("rename_user_conflict"); } static async Task RenameUserRowsAsync(SqliteConnection connection, SqliteTransaction transaction, string table, string oldUser, string newUser) { await using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = $"UPDATE {table} SET user = @newUser WHERE user = @oldUser COLLATE NOCASE;"; command.Parameters.AddWithValue("@oldUser", oldUser); command.Parameters.AddWithValue("@newUser", newUser); return await command.ExecuteNonQueryAsync(); } public static async Task DeleteUserAsync(DeleteUserRequest request) { if (request == null) throw new DatabaseEditorValidationException("request_required"); string user = ValidateKey(request.user, "user_required"); if (!File.Exists(Sync.path) || !File.Exists(TimeCode.path)) throw new DatabaseEditorValidationException("database_not_found"); var syncSemaphore = new SemaphorManager(Sync.semaphore, TimeSpan.FromSeconds(20)); var timecodeSemaphore = new SemaphorManager(TimeCode.semaphore, TimeSpan.FromSeconds(20)); bool syncAcquired = await syncSemaphore.WaitAsync(); if (!syncAcquired) throw new DatabaseEditorBusyException("database_busy"); bool timecodeAcquired = false; try { timecodeAcquired = await timecodeSemaphore.WaitAsync(); if (!timecodeAcquired) throw new DatabaseEditorBusyException("database_busy"); await using var connection = await OpenAsync(TimeCode, pooling: false); await using (var attach = connection.CreateCommand()) { attach.CommandText = "ATTACH DATABASE @syncPath AS syncdb;"; attach.Parameters.AddWithValue("@syncPath", Path.GetFullPath(Sync.path)); await attach.ExecuteNonQueryAsync(); } using var transaction = connection.BeginTransaction(); int timecodeRecords = await DeleteUserRowsAsync(connection, transaction, "main.timecodes", user); int syncRecords = await DeleteUserRowsAsync(connection, transaction, "syncdb.bookmarks", user); if (timecodeRecords == 0 && syncRecords == 0) throw new DatabaseEditorValidationException("user_not_found"); transaction.Commit(); Serilog.Log.Information("DatabaseEditor deleted user {User}: {SyncRecords} Sync, {TimecodeRecords} TimeCode records", user, syncRecords, timecodeRecords); return new DeleteUserResult { user = user, syncRecords = syncRecords, timecodeRecords = timecodeRecords }; } finally { if (timecodeAcquired) timecodeSemaphore.Release(); syncSemaphore.Release(); } } static async Task DeleteUserRowsAsync(SqliteConnection connection, SqliteTransaction transaction, string table, string user) { await using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = $"DELETE FROM {table} WHERE user = @user COLLATE NOCASE;"; command.Parameters.AddWithValue("@user", user); return await command.ExecuteNonQueryAsync(); } public static async Task BackupAsync(string database) { DatabaseSpec spec = GetSpec(database); var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20)); bool acquired = await semaphore.WaitAsync(); if (!acquired) throw new DatabaseEditorBusyException("database_busy"); try { string directory = Path.Combine("database", "backup", "database-editor"); Directory.CreateDirectory(directory); string fileName = $"{Path.GetFileNameWithoutExtension(spec.path)}-{DateTime.Now:yyyyMMdd-HHmmssfff}.sql"; string destinationPath = Path.Combine(directory, fileName); if (File.Exists(destinationPath)) destinationPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(spec.path)}-{DateTime.Now:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.sql"); await using var source = await OpenAsync(spec); var destinationBuilder = new SqliteConnectionStringBuilder { DataSource = destinationPath, Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false }; await using var destination = new SqliteConnection(destinationBuilder.ToString()); await destination.OpenAsync(); source.BackupDatabase(destination); Serilog.Log.Information("DatabaseEditor backed up {Database} to {BackupPath}", spec.key, destinationPath); return destinationPath.Replace('\\', '/'); } finally { semaphore.Release(); } } public static async Task> BackupAllAsync() { var result = new List(2) { new() { database = TimeCode.key, path = await BackupAsync(TimeCode.key) }, new() { database = Sync.key, path = await BackupAsync(Sync.key) } }; return result; } public static List GetBackups(string database) { DatabaseSpec spec = GetSpec(database); string directory = Path.Combine("database", "backup", "database-editor"); var result = new List(); if (!Directory.Exists(directory)) return result; string prefix = Path.GetFileNameWithoutExtension(spec.path) + "-"; foreach (string path in Directory.GetFiles(directory, prefix + "*.sql")) { var file = new FileInfo(path); result.Add(new DatabaseBackupFile { database = spec.key, file = file.Name, path = path.Replace('\\', '/'), bytes = file.Length, created = file.LastWriteTimeUtc.ToString("O") }); } result.Sort((left, right) => string.CompareOrdinal(right.created, left.created)); return result; } public static async Task RestoreAsync(RestoreBackupRequest request) { if (request == null) throw new DatabaseEditorValidationException("request_required"); DatabaseSpec spec = GetSpec(request.database); string fileName = Path.GetFileName((request.file ?? string.Empty).Trim()); if (string.IsNullOrEmpty(fileName) || !string.Equals(fileName, request.file?.Trim(), StringComparison.Ordinal)) throw new DatabaseEditorValidationException("invalid_backup_file"); string prefix = Path.GetFileNameWithoutExtension(spec.path) + "-"; if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || !fileName.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) throw new DatabaseEditorValidationException("invalid_backup_file"); string directory = Path.GetFullPath(Path.Combine("database", "backup", "database-editor")); string sourcePath = Path.GetFullPath(Path.Combine(directory, fileName)); if (!sourcePath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(sourcePath)) throw new DatabaseEditorValidationException("backup_not_found"); var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20)); bool acquired = await semaphore.WaitAsync(); if (!acquired) throw new DatabaseEditorBusyException("database_busy"); try { await ValidateBackupAsync(spec, sourcePath); string safetyPath = await CreateBackupLockedAsync(spec, "before-restore"); var sourceBuilder = new SqliteConnectionStringBuilder { DataSource = sourcePath, Mode = SqliteOpenMode.ReadOnly, Pooling = false }; await using (var source = new SqliteConnection(sourceBuilder.ToString())) { await source.OpenAsync(); await using var destination = await OpenAsync(spec); source.BackupDatabase(destination); } SqliteConnection.ClearAllPools(); Serilog.Log.Warning("DatabaseEditor restored {Database} from {BackupPath}; safety backup: {SafetyPath}", spec.key, sourcePath, safetyPath); return new DatabaseRestoreResult { database = spec.key, restoredFrom = sourcePath.Replace('\\', '/'), safetyBackup = safetyPath }; } finally { semaphore.Release(); } } static async Task ValidateBackupAsync(DatabaseSpec spec, string sourcePath) { try { var builder = new SqliteConnectionStringBuilder { DataSource = sourcePath, Mode = SqliteOpenMode.ReadOnly, Pooling = false }; await using var connection = new SqliteConnection(builder.ToString()); await connection.OpenAsync(); await using var integrity = connection.CreateCommand(); integrity.CommandText = "PRAGMA quick_check;"; if (!string.Equals(Convert.ToString(await integrity.ExecuteScalarAsync()), "ok", StringComparison.OrdinalIgnoreCase)) throw new DatabaseEditorValidationException("backup_integrity_failed"); await using var schema = connection.CreateCommand(); schema.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = @table LIMIT 1;"; schema.Parameters.AddWithValue("@table", spec.table); if (await schema.ExecuteScalarAsync() == null) throw new DatabaseEditorValidationException("backup_schema_mismatch"); } catch (SqliteException) { throw new DatabaseEditorValidationException("invalid_backup_database"); } } static async Task CreateBackupLockedAsync(DatabaseSpec spec, string marker) { string directory = Path.Combine("database", "backup", "database-editor"); Directory.CreateDirectory(directory); string baseName = Path.GetFileNameWithoutExtension(spec.path); string destinationPath = Path.Combine(directory, $"{baseName}-{marker}-{DateTime.Now:yyyyMMdd-HHmmssfff}.sql"); await using var source = await OpenAsync(spec); var builder = new SqliteConnectionStringBuilder { DataSource = destinationPath, Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false }; await using var destination = new SqliteConnection(builder.ToString()); await destination.OpenAsync(); source.BackupDatabase(destination); return destinationPath.Replace('\\', '/'); } static async Task EnrichTimeCodeRecordsAsync(List records) { if (!File.Exists(Sync.path)) return; var users = new HashSet(StringComparer.Ordinal); foreach (DatabaseRecord record in records) { if (!string.IsNullOrEmpty(record.user)) users.Add(record.user); if (TryParseTimeCodeCard(record.card, out _, out string mediaType)) record.mediaType = mediaType; } if (users.Count == 0) return; var metadata = new Dictionary(StringComparer.Ordinal); await using var connection = await OpenAsync(Sync); await using var command = connection.CreateCommand(); var placeholders = new List(users.Count); int parameterIndex = 0; foreach (string user in users) { string parameter = "@user" + parameterIndex++; placeholders.Add(parameter); command.Parameters.AddWithValue(parameter, user); } command.CommandText = $"SELECT user, data FROM bookmarks WHERE user IN ({string.Join(",", placeholders)});"; await using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { string user = ReadString(reader, 0); JsonObject root = ParseRoot(ReadString(reader, 1)); if (root?["card"] is not JsonArray cards) continue; foreach (JsonNode node in cards) { if (node is not JsonObject card) continue; string cardId = NodeText(card["id"]); if (string.IsNullOrEmpty(cardId)) continue; metadata[MediaKey(user, cardId)] = ReadMediaMetadata(card); } } foreach (DatabaseRecord record in records) { if (!TryParseTimeCodeCard(record.card, out string cardId, out string mediaType)) continue; record.mediaType = mediaType; if (!metadata.TryGetValue(MediaKey(record.user, cardId), out MediaMetadata media)) continue; record.title = media.title; record.poster = media.poster; record.year = media.year; if (string.IsNullOrEmpty(record.mediaType)) record.mediaType = media.mediaType; if (string.Equals(record.mediaType, "tv", StringComparison.OrdinalIgnoreCase) && TryFindEpisode(media, record.item, out int season, out int episode)) { record.season = season; record.episode = episode; } } } static SyncUserDetails BuildSyncUserDetails(long id, string user, string updated, string data) { JsonObject root = ParseRoot(data); var categoryMap = new Dictionary>(StringComparer.Ordinal); var categoryOrderMap = new Dictionary>(StringComparer.Ordinal); var allCategoryIds = new List(); foreach (string category in SyncCategories) { var ids = new HashSet(StringComparer.Ordinal); var positions = new Dictionary(StringComparer.Ordinal); if (root?[category] is JsonArray array) { foreach (JsonNode node in array) { string cardId = NodeText(node); if (!string.IsNullOrEmpty(cardId) && ids.Add(cardId)) { positions[cardId] = positions.Count; allCategoryIds.Add(cardId); } } } categoryMap[category] = ids; categoryOrderMap[category] = positions; } var items = new List(); var seen = new HashSet(StringComparer.Ordinal); if (root?["card"] is JsonArray cards) { foreach (JsonNode node in cards) { if (node is not JsonObject card) continue; string cardId = NodeText(card["id"]); if (string.IsNullOrEmpty(cardId) || !seen.Add(cardId)) continue; MediaMetadata media = ReadMediaMetadata(card); items.Add(new SyncUserItem { cardId = cardId, title = media.title, poster = media.poster, year = media.year, mediaType = media.mediaType, categories = CategoriesFor(cardId, categoryMap), order = items.Count, categoryOrder = CategoryOrderFor(cardId, categoryOrderMap) }); } } foreach (string cardId in allCategoryIds) { if (!seen.Add(cardId)) continue; items.Add(new SyncUserItem { cardId = cardId, title = "Карточка #" + cardId, categories = CategoriesFor(cardId, categoryMap), order = items.Count, categoryOrder = CategoryOrderFor(cardId, categoryOrderMap) }); } return new SyncUserDetails { id = id, user = user, updated = updated, total = items.Count, items = items }; } static async Task UpdateSyncJsonAsync(long recordId, Action update) { var semaphore = new SemaphorManager(Sync.semaphore, TimeSpan.FromSeconds(20)); bool acquired = await semaphore.WaitAsync(); if (!acquired) throw new DatabaseEditorBusyException("database_busy"); try { await using var connection = await OpenAsync(Sync); using var transaction = connection.BeginTransaction(); string data; await using (var select = connection.CreateCommand()) { select.Transaction = transaction; select.CommandText = "SELECT data FROM bookmarks WHERE Id = @id LIMIT 1;"; select.Parameters.AddWithValue("@id", recordId); data = Convert.ToString(await select.ExecuteScalarAsync()); } if (string.IsNullOrEmpty(data)) throw new DatabaseEditorValidationException("record_not_found"); JsonObject root = ParseRoot(data) ?? throw new DatabaseEditorValidationException("invalid_json"); update(root); string updatedData = root.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); if (Encoding.UTF8.GetByteCount(updatedData) > MaxDataBytes) throw new DatabaseEditorValidationException("data_too_large"); await using var save = connection.CreateCommand(); save.Transaction = transaction; save.CommandText = "UPDATE bookmarks SET data = @data, updated = @updated WHERE Id = @id;"; save.Parameters.AddWithValue("@data", updatedData); save.Parameters.AddWithValue("@updated", DateTime.UtcNow.ToString("O")); save.Parameters.AddWithValue("@id", recordId); if (await save.ExecuteNonQueryAsync() == 0) throw new DatabaseEditorValidationException("record_not_found"); transaction.Commit(); } finally { semaphore.Release(); } } static MediaMetadata ReadMediaMetadata(JsonObject card) { string mediaType = NodeText(card?["media_type"]); if (string.IsNullOrEmpty(mediaType)) mediaType = card?["name"] != null && card?["title"] == null ? "tv" : "movie"; string date = FirstNotEmpty(NodeText(card?["release_date"]), NodeText(card?["first_air_date"])); return new MediaMetadata { title = FirstNotEmpty(NodeText(card?["title"]), NodeText(card?["name"]), NodeText(card?["original_title"]), NodeText(card?["original_name"])), poster = FirstNotEmpty(NodeText(card?["img"]), NodeText(card?["poster_path"])), year = !string.IsNullOrEmpty(date) && date.Length >= 4 ? date.Substring(0, 4) : date, mediaType = mediaType, hashTitles = DistinctValues( NodeText(card?["original_name"]), NodeText(card?["original_title"]), NodeText(card?["name"]), NodeText(card?["title"])), seasons = ReadPositiveInt(card?["number_of_seasons"]), episodes = ReadPositiveInt(card?["number_of_episodes"]) }; } static bool TryFindEpisode(MediaMetadata media, string item, out int season, out int episode) { season = 0; episode = 0; if (media?.hashTitles == null || media.hashTitles.Count == 0 || !long.TryParse(item, NumberStyles.None, CultureInfo.InvariantCulture, out long target) || target < 0 || target > 2147483648L) return false; int maxSeasons = media.seasons > 0 ? Math.Min(media.seasons, 100) : 50; int maxEpisodes = media.episodes > 0 ? Math.Min(media.episodes, 3000) : 500; maxSeasons = Math.Max(maxSeasons, 10); maxEpisodes = Math.Max(maxEpisodes, 100); if (FindEpisode(media.hashTitles, target, maxSeasons, maxEpisodes, out season, out episode)) return true; // Long-running daily shows and anime can exceed the compact first pass. if (maxSeasons < 100 || maxEpisodes < 3000) return FindEpisode(media.hashTitles, target, 100, 3000, out season, out episode); return false; } static bool FindEpisode(List titles, long target, int maxSeasons, int maxEpisodes, out int season, out int episode) { var titleHashes = new List<(int hash, int power)>(titles.Count); foreach (string title in titles) { int power = 1; for (int index = 0; index < title.Length; index++) power = unchecked(power * 31); titleHashes.Add((JsHash(title), power)); } for (int seasonNumber = 0; seasonNumber <= maxSeasons; seasonNumber++) { string separator = seasonNumber > 10 ? ":" : string.Empty; string seasonPrefix = seasonNumber.ToString(CultureInfo.InvariantCulture) + separator; for (int episodeNumber = 0; episodeNumber <= maxEpisodes; episodeNumber++) { int prefixHash = JsHash(seasonPrefix + episodeNumber.ToString(CultureInfo.InvariantCulture)); foreach ((int titleHash, int power) in titleHashes) { int hash = unchecked(prefixHash * power + titleHash); if (Math.Abs((long)hash) != target) continue; season = seasonNumber; episode = episodeNumber; return true; } } } season = 0; episode = 0; return false; } static int JsHash(string value) { int hash = 0; foreach (char character in value ?? string.Empty) hash = unchecked(hash * 31 + character); return hash; } static List DistinctValues(params string[] values) { var result = new List(); var seen = new HashSet(StringComparer.Ordinal); foreach (string value in values) { if (!string.IsNullOrWhiteSpace(value) && seen.Add(value)) result.Add(value); } return result; } static int ReadPositiveInt(JsonNode node) { return int.TryParse(NodeText(node), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) && value > 0 ? value : 0; } static List CategoriesFor(string cardId, Dictionary> categoryMap) { var result = new List(); foreach (string category in SyncEditorCategories) { if (categoryMap[category].Contains(cardId)) result.Add(category); } return result; } static Dictionary CategoryOrderFor(string cardId, Dictionary> categoryOrderMap) { var result = new Dictionary(StringComparer.Ordinal); foreach (string category in SyncEditorCategories) { if (categoryOrderMap[category].TryGetValue(cardId, out int position)) result[category] = position; } return result; } static JsonObject FindSyncCard(JsonObject root, string cardId) { if (root?["card"] is not JsonArray cards) return null; foreach (JsonNode node in cards) { if (node is JsonObject card && string.Equals(NodeText(card["id"]), cardId, StringComparison.Ordinal)) return card; } return null; } static JsonArray EnsureArray(JsonObject root, string name) { if (root[name] is JsonArray array) return array; array = new JsonArray(); root[name] = array; return array; } static bool RemoveCardId(JsonArray array, string cardId) { bool removed = false; for (int index = array.Count - 1; index >= 0; index--) { if (string.Equals(NodeText(array[index]), cardId, StringComparison.Ordinal)) { array.RemoveAt(index); removed = true; } } return removed; } static bool ContainsCardId(JsonArray array, string cardId) { foreach (JsonNode node in array) { if (string.Equals(NodeText(node), cardId, StringComparison.Ordinal)) return true; } return false; } static JsonNode CreateCardIdNode(string cardId) { if (long.TryParse(cardId, out long numericId)) return JsonValue.Create(numericId); return JsonValue.Create(cardId); } static JsonObject ParseRoot(string data) { if (string.IsNullOrWhiteSpace(data)) return null; try { return JsonNode.Parse(data) as JsonObject; } catch (JsonException) { return null; } } static string NodeText(JsonNode node) { if (node == null) return null; if (node is JsonValue value && value.TryGetValue(out string text)) return text; return node.ToJsonString().Trim('"'); } static string FirstNotEmpty(params string[] values) { foreach (string value in values) { if (!string.IsNullOrWhiteSpace(value)) return value; } return null; } static string MediaKey(string user, string cardId) => (user ?? string.Empty) + "\u001f" + cardId; static bool TryParseTimeCodeCard(string value, out string cardId, out string mediaType) { cardId = null; mediaType = null; if (string.IsNullOrWhiteSpace(value)) return false; int separator = value.LastIndexOf('_'); if (separator <= 0 || separator == value.Length - 1) return false; string suffix = value.Substring(separator + 1); if (!string.Equals(suffix, "movie", StringComparison.OrdinalIgnoreCase) && !string.Equals(suffix, "tv", StringComparison.OrdinalIgnoreCase)) return false; cardId = value.Substring(0, separator); mediaType = suffix.ToLowerInvariant(); return true; } static void ReadPlaybackData(DatabaseRecord record, string data) { if (string.IsNullOrWhiteSpace(data)) return; try { using var document = JsonDocument.Parse(data); JsonElement root = document.RootElement; record.position = ReadDouble(root, "time"); record.duration = ReadDouble(root, "duration"); record.percent = ReadDouble(root, "percent"); } catch (JsonException) { } } static double? ReadDouble(JsonElement root, string name) { if (!root.TryGetProperty(name, out JsonElement value)) return null; if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out double number)) return number; if (value.ValueKind == JsonValueKind.String && double.TryParse(value.GetString(), out number)) return number; return null; } static async Task ReadSummaryAsync(DatabaseSpec spec) { var summary = new DatabaseSummary { database = spec.key, title = spec.title, file = spec.path.Replace('\\', '/'), available = File.Exists(spec.path), bytes = File.Exists(spec.path) ? new FileInfo(spec.path).Length : 0 }; if (!summary.available) return summary; await using var connection = await OpenAsync(spec); await using var command = connection.CreateCommand(); command.CommandText = $"SELECT COUNT(*), {spec.updatedText($"MAX({spec.updatedColumn})")} FROM {spec.table};"; await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow); if (await reader.ReadAsync()) { summary.records = reader.GetInt64(0); summary.updated = ReadString(reader, 1); } return summary; } static DatabaseSpec GetSpec(string database) { if (string.Equals(database, Sync.key, StringComparison.OrdinalIgnoreCase)) return Sync; if (string.Equals(database, TimeCode.key, StringComparison.OrdinalIgnoreCase)) return TimeCode; throw new DatabaseEditorValidationException("unknown_database"); } static async Task OpenAsync(DatabaseSpec spec, bool pooling = true) { if (!File.Exists(spec.path)) throw new DatabaseEditorValidationException("database_not_found"); var builder = new SqliteConnectionStringBuilder { DataSource = spec.path, Mode = SqliteOpenMode.ReadWrite, Cache = SqliteCacheMode.Shared, DefaultTimeout = 10, Pooling = pooling }; var connection = new SqliteConnection(builder.ToString()); await connection.OpenAsync(); return connection; } static string BuildWhere(DatabaseSpec spec, string query, string user, out string searchValue, out string selectedUser) { var clauses = new List(); if (string.IsNullOrEmpty(query)) { searchValue = null; } else { searchValue = "%" + query.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_") + "%"; clauses.Add(spec.timecode ? "(CAST(Id AS TEXT) LIKE @search ESCAPE '\\' OR user LIKE @search ESCAPE '\\' COLLATE NOCASE OR identity LIKE @search ESCAPE '\\' COLLATE NOCASE OR card LIKE @search ESCAPE '\\' COLLATE NOCASE OR item LIKE @search ESCAPE '\\' COLLATE NOCASE)" : "(CAST(Id AS TEXT) LIKE @search ESCAPE '\\' OR user LIKE @search ESCAPE '\\' COLLATE NOCASE OR data LIKE @search ESCAPE '\\' COLLATE NOCASE)"); } selectedUser = string.IsNullOrEmpty(user) ? null : user; if (selectedUser != null) clauses.Add("user = @selectedUser COLLATE NOCASE"); return clauses.Count == 0 ? string.Empty : " WHERE " + string.Join(" AND ", clauses); } static void AddFilters(SqliteCommand command, string searchValue, string selectedUser) { if (searchValue != null) command.Parameters.AddWithValue("@search", searchValue); if (selectedUser != null) command.Parameters.AddWithValue("@selectedUser", selectedUser); } static string ValidateKey(string value, string error) { value = (value ?? string.Empty).Trim(); if (value.Length == 0) throw new DatabaseEditorValidationException(error); if (value.Length > MaxKeyLength) throw new DatabaseEditorValidationException("key_too_long"); return value; } static string NormalizeJson(string value) { if (string.IsNullOrWhiteSpace(value)) throw new DatabaseEditorValidationException("data_required"); if (Encoding.UTF8.GetByteCount(value) > MaxDataBytes) throw new DatabaseEditorValidationException("data_too_large"); try { using var document = JsonDocument.Parse(value); if (document.RootElement.ValueKind != JsonValueKind.Object) throw new DatabaseEditorValidationException("data_must_be_json_object"); return JsonSerializer.Serialize(document.RootElement); } catch (JsonException) { throw new DatabaseEditorValidationException("invalid_json"); } } static string BuildPreview(string value) { if (string.IsNullOrEmpty(value)) return string.Empty; string preview = value.Replace("\r", " ").Replace("\n", " ").Replace("\t", " "); while (preview.Contains(" ", StringComparison.Ordinal)) preview = preview.Replace(" ", " ", StringComparison.Ordinal); return preview.Length <= 240 ? preview : preview.Substring(0, 240) + "…"; } static string ReadString(SqliteDataReader reader, int index) => reader.IsDBNull(index) ? null : reader.GetString(index); }