using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.DbConfiguration; using MediaBrowser.Common.Configuration; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging; namespace Jellyfin.Database.Providers.Sqlite; /// /// Configures jellyfin to use an SQLite database. /// [JellyfinDatabaseProviderKey("Jellyfin-SQLite")] public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider { private const string BackupFolderName = "SQLiteBackups"; private readonly IApplicationPaths _applicationPaths; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// Service to construct the fallback when the old data path configuration is used. /// A logger. public SqliteDatabaseProvider(IApplicationPaths applicationPaths, ILogger logger) { _applicationPaths = applicationPaths; _logger = logger; } /// public IDbContextFactory? DbContextFactory { get; set; } /// public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration) { var sqliteConnectionBuilder = new SqliteConnectionStringBuilder(); sqliteConnectionBuilder.DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); sqliteConnectionBuilder.Cache = Enum.Parse(databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("cache", StringComparison.OrdinalIgnoreCase))?.Value ?? nameof(SqliteCacheMode.Default)); sqliteConnectionBuilder.Pooling = (databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("pooling", StringComparison.OrdinalIgnoreCase))?.Value ?? bool.FalseString).Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase); options .UseSqlite( sqliteConnectionBuilder.ToString(), sqLiteOptions => sqLiteOptions.MigrationsAssembly(GetType().Assembly)) // TODO: Remove when https://github.com/dotnet/efcore/pull/35873 is merged & released .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.NonTransactionalMigrationOperationWarning)); } /// public async Task RunScheduledOptimisation(CancellationToken cancellationToken) { var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { if (context.Database.IsSqlite()) { await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); _logger.LogInformation("jellyfin.db optimized successfully!"); } else { _logger.LogInformation("This database doesn't support optimization"); } } } /// public void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc); } /// public async Task RunShutdownTask(CancellationToken cancellationToken) { if (DbContextFactory is null) { return; } // Run before disposing the application var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); } SqliteConnection.ClearAllPools(); } /// public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { configurationBuilder.Conventions.Add(_ => new DoNotUseReturningClauseConvention()); } /// public Task MigrationBackupFast(CancellationToken cancellationToken) { var key = DateTime.UtcNow.ToString("yyyyMMddhhmmss", CultureInfo.InvariantCulture); var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName); Directory.CreateDirectory(backupFile); backupFile = Path.Combine(backupFile, $"{key}_jellyfin.db"); File.Copy(path, backupFile); return Task.FromResult(key); } /// public Task RestoreBackupFast(string key, CancellationToken cancellationToken) { // ensure there are absolutly no dangling Sqlite connections. SqliteConnection.ClearAllPools(); var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName, $"{key}_jellyfin.db"); if (!File.Exists(backupFile)) { _logger.LogCritical("Tried to restore a backup that does not exist: {Key}", key); return Task.CompletedTask; } File.Copy(backupFile, path, true); return Task.CompletedTask; } /// public Task DeleteBackup(string key) { var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName, $"{key}_jellyfin.db"); if (!File.Exists(backupFile)) { _logger.LogCritical("Tried to delete a backup that does not exist: {Key}", key); return Task.CompletedTask; } File.Delete(backupFile); return Task.CompletedTask; } /// public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable? tableNames) { ArgumentNullException.ThrowIfNull(tableNames); var deleteQueries = new List(); foreach (var tableName in tableNames) { deleteQueries.Add($"DELETE FROM \"{tableName}\";"); } var deleteAllQuery = $""" PRAGMA foreign_keys = OFF; {string.Join('\n', deleteQueries)} PRAGMA foreign_keys = ON; """; await dbContext.Database.ExecuteSqlRawAsync(deleteAllQuery).ConfigureAwait(false); } }