Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules

This commit is contained in:
2026-10-02 12:11:49 +03:00
parent 8dcbc6bc52
commit 7b8e28b3bf
151 changed files with 68625 additions and 44 deletions
+614
View File
@@ -0,0 +1,614 @@
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<MusicContext> 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<IDbContextFactory<MusicContext>>();
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<MusicAuthCredentialSqlModel> auth_credentials { get; set; }
public DbSet<MusicPlaybackHistorySqlModel> 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<MusicAuthCredentialSqlModel>()
.HasIndex(t => new { t.profile_id, t.provider_id })
.IsUnique();
modelBuilder.Entity<MusicPlaybackHistorySqlModel>()
.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<byte> 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; }
}