using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using System.ComponentModel.DataAnnotations; using System.Text; using System.Threading; namespace Music; public class MusicContext : DbContext { public static readonly string semaphoreKey = "Music"; static readonly string DatabaseDirectoryPath = Path.Combine("database", "music"); static readonly string DatabaseFilePath = Path.Combine(DatabaseDirectoryPath, "Music.sql"); static readonly string BackupDirectoryPath = Path.Combine(DatabaseDirectoryPath, "backups"); static readonly TimeSpan BackupInterval = TimeSpan.FromHours(6); static readonly TimeSpan MinBackupSpacing = TimeSpan.FromHours(1); const int MaxBackupFiles = 5; static readonly SemaphoreSlim backupLock = new(1, 1); static Timer backupTimer; public static IDbContextFactory Factory { get; private set; } public static MusicContext Create() { if (Factory != null) return Factory.CreateDbContext(); return new MusicContext(); } public static void Initialization(IServiceProvider applicationServices) { Directory.CreateDirectory(DatabaseDirectoryPath); Directory.CreateDirectory(BackupDirectoryPath); ValidateOrRecoverDatabase(); Factory = applicationServices.GetService>(); bool schemaChanged; using (var sqlDb = Factory?.CreateDbContext() ?? new MusicContext()) { ApplyConnectionPragmas(sqlDb.Database.GetDbConnection()); sqlDb.Database.EnsureCreated(); schemaChanged = EnsureSchema(sqlDb); } if (schemaChanged) CompactDatabase(); CreateBackupSnapshotIfNeeded(force: schemaChanged); TrimOldBackups(); StartBackupTimer(); } static readonly string _connection = new SqliteConnectionStringBuilder { DataSource = DatabaseFilePath, Mode = SqliteOpenMode.ReadWriteCreate, Cache = SqliteCacheMode.Private, DefaultTimeout = 30, Pooling = false }.ToString(); public static string ConnectionString => _connection; public DbSet auth_credentials { get; set; } public DbSet playback_history { get; set; } public static void ConfiguringDbBuilder(DbContextOptionsBuilder optionsBuilder) { if (!optionsBuilder.IsConfigured) { optionsBuilder.UseSqlite(_connection); optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); } } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { ConfiguringDbBuilder(optionsBuilder); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() .HasIndex(t => new { t.profile_id, t.provider_id }) .IsUnique(); modelBuilder.Entity() .HasIndex(t => new { t.profile_id, t.track_id }) .IsUnique(); } static bool EnsureSchema(MusicContext db) { bool changed = false; db.Database.ExecuteSqlRaw(""" CREATE TABLE IF NOT EXISTS auth_credentials ( Id INTEGER NOT NULL CONSTRAINT PK_auth_credentials PRIMARY KEY AUTOINCREMENT, profile_id TEXT NOT NULL DEFAULT '', provider_id TEXT NOT NULL, payload TEXT NOT NULL, updated TEXT NOT NULL ); """); changed |= EnsureColumn(db, "auth_credentials", "profile_id", "TEXT NOT NULL DEFAULT ''"); db.Database.ExecuteSqlRaw(""" DROP INDEX IF EXISTS IX_auth_credentials_provider_id; """); db.Database.ExecuteSqlRaw(""" CREATE UNIQUE INDEX IF NOT EXISTS IX_auth_credentials_profile_id_provider_id ON auth_credentials (profile_id, provider_id); """); db.Database.ExecuteSqlRaw(""" CREATE TABLE IF NOT EXISTS playback_history ( Id INTEGER NOT NULL CONSTRAINT PK_playback_history PRIMARY KEY AUTOINCREMENT, profile_id TEXT NOT NULL DEFAULT '', track_id TEXT NOT NULL, payload TEXT NOT NULL, updated TEXT NOT NULL ); """); changed |= EnsureColumn(db, "playback_history", "profile_id", "TEXT NOT NULL DEFAULT ''"); db.Database.ExecuteSqlRaw(""" DROP INDEX IF EXISTS IX_playback_history_track_id; """); db.Database.ExecuteSqlRaw(""" CREATE UNIQUE INDEX IF NOT EXISTS IX_playback_history_profile_id_track_id ON playback_history (profile_id, track_id); """); db.Database.ExecuteSqlRaw(""" CREATE INDEX IF NOT EXISTS IX_playback_history_profile_id_updated ON playback_history (profile_id, updated DESC); """); db.Database.ExecuteSqlRaw(""" CREATE TABLE IF NOT EXISTS audio_source_matches ( Id INTEGER NOT NULL CONSTRAINT PK_audio_source_matches PRIMARY KEY AUTOINCREMENT, track_id TEXT NOT NULL, provider_scope TEXT NOT NULL, payload TEXT NOT NULL, updated TEXT NOT NULL ); """); db.Database.ExecuteSqlRaw(""" CREATE UNIQUE INDEX IF NOT EXISTS IX_audio_source_matches_track_id_provider_scope ON audio_source_matches (track_id, provider_scope); """); // в durable-таблице живёт только ручной выбор (pinned) — авто-матчи // хранятся в volatile cache; заодно чистит авто-записи, попавшие сюда // в промежуточной ревизии, когда SaveAsync писал в базу всё подряд db.Database.ExecuteSqlRaw(""" DELETE FROM audio_source_matches WHERE payload NOT LIKE '%"pinned":true%'; """); // дневная статистика прослушиваний: только счётчики, payload трека // живёт в playback_history (джойн по track_id при чтении); дневная // гранулярность нужна для окон «месяц/год/на повторе/забытые» db.Database.ExecuteSqlRaw(""" CREATE TABLE IF NOT EXISTS track_stats_daily ( Id INTEGER NOT NULL CONSTRAINT PK_track_stats_daily PRIMARY KEY AUTOINCREMENT, profile_id TEXT NOT NULL DEFAULT '', track_id TEXT NOT NULL, day TEXT NOT NULL, play_count INTEGER NOT NULL DEFAULT 0, total_ms INTEGER NOT NULL DEFAULT 0, last_played TEXT NOT NULL ); """); db.Database.ExecuteSqlRaw(""" CREATE UNIQUE INDEX IF NOT EXISTS IX_track_stats_daily_profile_track_day ON track_stats_daily (profile_id, track_id, day); """); db.Database.ExecuteSqlRaw(""" CREATE INDEX IF NOT EXISTS IX_track_stats_daily_profile_day ON track_stats_daily (profile_id, day); """); db.Database.ExecuteSqlRaw(""" CREATE TABLE IF NOT EXISTS user_playlists ( Id INTEGER NOT NULL CONSTRAINT PK_user_playlists PRIMARY KEY AUTOINCREMENT, profile_id TEXT NOT NULL DEFAULT '', playlist_id TEXT NOT NULL, title TEXT NOT NULL, payload TEXT NOT NULL, source TEXT NOT NULL DEFAULT '', updated TEXT NOT NULL ); """); changed |= EnsureColumn(db, "user_playlists", "source", "TEXT NOT NULL DEFAULT ''"); db.Database.ExecuteSqlRaw(""" CREATE UNIQUE INDEX IF NOT EXISTS IX_user_playlists_profile_id_playlist_id ON user_playlists (profile_id, playlist_id); """); changed |= DropLegacyCacheTables(db); return changed; } static bool EnsureColumn(MusicContext db, string tableName, string columnName, string definition) { using var connection = new SqliteConnection(_connection); connection.Open(); using var exists = connection.CreateCommand(); exists.CommandText = $"PRAGMA table_info({tableName});"; using var reader = exists.ExecuteReader(); while (reader.Read()) { if (string.Equals(reader["name"]?.ToString(), columnName, StringComparison.OrdinalIgnoreCase)) return false; } using var alter = connection.CreateCommand(); alter.CommandText = $"ALTER TABLE {tableName} ADD COLUMN {columnName} {definition};"; alter.ExecuteNonQuery(); return true; } static bool DropLegacyCacheTables(MusicContext db) { bool changed = TableExists("metadata_cache") || TableExists("source_matches"); db.Database.ExecuteSqlRaw(""" DROP INDEX IF EXISTS IX_metadata_cache_provider_id_entity_type_cache_key; DROP INDEX IF EXISTS IX_source_matches_track_id_provider_id; DROP TABLE IF EXISTS metadata_cache; DROP TABLE IF EXISTS source_matches; """); return changed; } static bool TableExists(string tableName) { using var connection = new SqliteConnection(_connection); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = """ SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = $table_name LIMIT 1; """; command.Parameters.AddWithValue("$table_name", tableName); return command.ExecuteScalar() != null; } static void ApplyConnectionPragmas(System.Data.Common.DbConnection dbConnection) { if (dbConnection is not SqliteConnection connection) return; bool shouldClose = connection.State != System.Data.ConnectionState.Open; if (shouldClose) connection.Open(); try { using var command = connection.CreateCommand(); command.CommandText = """ PRAGMA busy_timeout = 10000; PRAGMA synchronous = FULL; """; command.ExecuteNonQuery(); } finally { if (shouldClose) connection.Close(); } } static void StartBackupTimer() { backupTimer ??= new Timer(_ => { try { CreateBackupSnapshotIfNeeded(force: false); } catch { } }, null, BackupInterval, BackupInterval); } static void CompactDatabase() { if (!File.Exists(DatabaseFilePath)) return; using var connection = new SqliteConnection(_connection); connection.Open(); ApplyConnectionPragmas(connection); using var checkpoint = connection.CreateCommand(); checkpoint.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);"; checkpoint.ExecuteNonQuery(); using var vacuum = connection.CreateCommand(); vacuum.CommandText = "VACUUM;"; vacuum.ExecuteNonQuery(); } static void ValidateOrRecoverDatabase() { if (!DatabaseFileExists(DatabaseFilePath)) return; if (IsDatabaseValid(DatabaseFilePath)) return; string corruptPath = Path.Combine(DatabaseDirectoryPath, $"Music.sql.corrupt-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}"); // If quarantine fails, stop initialization. Never overwrite the original // or discard its WAL after a failed move. File.Move(DatabaseFilePath, corruptPath); // сайдкары битой базы нельзя оставлять рядом с восстановленным файлом: // SQLite попытается проиграть чужой WAL и испортит свежую копию try { QuarantineSidecarFiles(corruptPath); RestoreLatestValidBackup(); } catch { // Leave the original in place for the next startup attempt if a // sidecar or backup is temporarily inaccessible. foreach (var suffix in new[] { "-wal", "-shm" }) { if (DatabaseFileExists(corruptPath + suffix)) File.Move(corruptPath + suffix, DatabaseFilePath + suffix); } File.Move(corruptPath, DatabaseFilePath); throw; } } static void QuarantineSidecarFiles(string corruptPath) { foreach (var suffix in new[] { "-wal", "-shm" }) { string sidecar = DatabaseFilePath + suffix; if (DatabaseFileExists(sidecar)) File.Move(sidecar, corruptPath + suffix); } } static bool RestoreLatestValidBackup() { if (!Directory.Exists(BackupDirectoryPath)) return false; foreach (var backupFile in Directory.GetFiles(BackupDirectoryPath, "Music-*.sqlite") .OrderByDescending(File.GetLastWriteTimeUtc)) { if (!IsDatabaseValid(backupFile)) continue; string restorePath = DatabaseFilePath + ".restore-" + Guid.NewGuid().ToString("N"); try { File.Copy(backupFile, restorePath); File.Move(restorePath, DatabaseFilePath); } finally { if (File.Exists(restorePath)) File.Delete(restorePath); } return true; } return false; } static bool DatabaseFileExists(string path) { // File.Exists also returns false on permission/I/O errors. Those must // not be mistaken for a missing database during recovery. try { File.GetAttributes(path); return true; } catch (FileNotFoundException) { return false; } catch (DirectoryNotFoundException) { return false; } } static bool IsDatabaseValid(string path) { try { if (!DatabaseFileExists(path)) return false; var info = new FileInfo(path); if (info.Length < 100) return false; using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { Span header = stackalloc byte[16]; if (stream.Read(header) != header.Length) return false; if (!header.SequenceEqual(Encoding.ASCII.GetBytes("SQLite format 3\0"))) return false; } var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly, Pooling = false, DefaultTimeout = 5 }; using var connection = new SqliteConnection(builder.ToString()); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = "PRAGMA quick_check;"; var result = command.ExecuteScalar()?.ToString(); return string.Equals(result, "ok", StringComparison.OrdinalIgnoreCase); } catch (SqliteException ex) when (ex.SqliteErrorCode == 11 || ex.SqliteErrorCode == 26) { // SQLITE_CORRUPT / SQLITE_NOTADB are evidence of corruption. // Busy, locked, permission and I/O failures propagate, preserving // the database instead of silently rolling it back to a backup. return false; } } static void CreateBackupSnapshotIfNeeded(bool force) { if (!backupLock.Wait(0)) return; try { Directory.CreateDirectory(BackupDirectoryPath); if (!force) { var latest = Directory.GetFiles(BackupDirectoryPath, "Music-*.sqlite") .Select(path => new FileInfo(path)) .OrderByDescending(file => file.LastWriteTimeUtc) .FirstOrDefault(); if (latest != null && DateTime.UtcNow - latest.LastWriteTimeUtc < MinBackupSpacing) return; if (latest != null && File.GetLastWriteTimeUtc(DatabaseFilePath) <= latest.LastWriteTimeUtc) return; } if (!IsDatabaseValid(DatabaseFilePath)) return; string tempPath = Path.Combine(BackupDirectoryPath, $"Music-{DateTime.UtcNow:yyyyMMdd-HHmmss}.tmp"); string finalPath = Path.ChangeExtension(tempPath, ".sqlite"); try { if (File.Exists(tempPath)) File.Delete(tempPath); using var source = new SqliteConnection(_connection); source.Open(); ApplyConnectionPragmas(source); var backupBuilder = new SqliteConnectionStringBuilder { DataSource = tempPath, Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false }; using var destination = new SqliteConnection(backupBuilder.ToString()); destination.Open(); source.BackupDatabase(destination); } catch { try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } throw; } if (!IsDatabaseValid(tempPath)) { File.Delete(tempPath); TryDeleteSidecarFiles(tempPath); return; } if (File.Exists(finalPath)) File.Delete(finalPath); File.Move(tempPath, finalPath); TryDeleteSidecarFiles(tempPath); TrimOldBackups(); } finally { backupLock.Release(); } } static void TrimOldBackups() { var backupFiles = Directory.GetFiles(BackupDirectoryPath, "Music-*.sqlite") .Select(path => new FileInfo(path)) .OrderByDescending(file => file.LastWriteTimeUtc) .ToList(); foreach (var file in backupFiles) TryDeleteSidecarFiles(file.FullName); foreach (var file in backupFiles.Skip(MaxBackupFiles)) { try { file.Delete(); } catch { } } } static void TryDeleteSidecarFiles(string basePath) { foreach (var suffix in new[] { "-wal", "-shm" }) { try { string sidecar = basePath + suffix; if (File.Exists(sidecar)) File.Delete(sidecar); } catch { } } } } public class MusicAuthCredentialSqlModel { [Key] public long Id { get; set; } [Required] public string profile_id { get; set; } [Required] public string provider_id { get; set; } [Required] public string payload { get; set; } public DateTime updated { get; set; } } public class MusicPlaybackHistorySqlModel { [Key] public long Id { get; set; } [Required] public string profile_id { get; set; } [Required] public string track_id { get; set; } [Required] public string payload { get; set; } public DateTime updated { get; set; } }