| | | 1 | | using System; |
| | | 2 | | using System.Collections.Generic; |
| | | 3 | | using System.Data.Common; |
| | | 4 | | using System.Globalization; |
| | | 5 | | using System.IO; |
| | | 6 | | using System.Linq; |
| | | 7 | | using System.Threading; |
| | | 8 | | using System.Threading.Tasks; |
| | | 9 | | using Jellyfin.Database.Implementations; |
| | | 10 | | using Jellyfin.Database.Implementations.DbConfiguration; |
| | | 11 | | using MediaBrowser.Common.Configuration; |
| | | 12 | | using Microsoft.Data.Sqlite; |
| | | 13 | | using Microsoft.EntityFrameworkCore; |
| | | 14 | | using Microsoft.EntityFrameworkCore.Diagnostics; |
| | | 15 | | using Microsoft.Extensions.Logging; |
| | | 16 | | |
| | | 17 | | namespace Jellyfin.Database.Providers.Sqlite; |
| | | 18 | | |
| | | 19 | | /// <summary> |
| | | 20 | | /// Configures jellyfin to use an SQLite database. |
| | | 21 | | /// </summary> |
| | | 22 | | [JellyfinDatabaseProviderKey("Jellyfin-SQLite")] |
| | | 23 | | public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider |
| | | 24 | | { |
| | | 25 | | private const string BackupFolderName = "SQLiteBackups"; |
| | | 26 | | private readonly IApplicationPaths _applicationPaths; |
| | | 27 | | private readonly ILogger<SqliteDatabaseProvider> _logger; |
| | | 28 | | |
| | | 29 | | /// <summary> |
| | | 30 | | /// Initializes a new instance of the <see cref="SqliteDatabaseProvider"/> class. |
| | | 31 | | /// </summary> |
| | | 32 | | /// <param name="applicationPaths">Service to construct the fallback when the old data path configuration is used.</ |
| | | 33 | | /// <param name="logger">A logger.</param> |
| | | 34 | | public SqliteDatabaseProvider(IApplicationPaths applicationPaths, ILogger<SqliteDatabaseProvider> logger) |
| | | 35 | | { |
| | 953 | 36 | | _applicationPaths = applicationPaths; |
| | 953 | 37 | | _logger = logger; |
| | 953 | 38 | | } |
| | | 39 | | |
| | | 40 | | /// <inheritdoc/> |
| | | 41 | | public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; } |
| | | 42 | | |
| | | 43 | | /// <inheritdoc/> |
| | | 44 | | public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration) |
| | | 45 | | { |
| | | 46 | | static T? GetOption<T>(ICollection<CustomDatabaseOption>? options, string key, Func<string, T> converter, Func<T |
| | | 47 | | { |
| | | 48 | | if (options is null) |
| | | 49 | | { |
| | | 50 | | return defaultValue is not null ? defaultValue() : default; |
| | | 51 | | } |
| | | 52 | | |
| | | 53 | | var value = options.FirstOrDefault(e => e.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); |
| | | 54 | | if (value is null) |
| | | 55 | | { |
| | | 56 | | return defaultValue is not null ? defaultValue() : default; |
| | | 57 | | } |
| | | 58 | | |
| | | 59 | | return converter(value.Value); |
| | | 60 | | } |
| | | 61 | | |
| | 63 | 62 | | var customOptions = databaseConfiguration.CustomProviderOptions?.Options; |
| | | 63 | | |
| | 63 | 64 | | var sqliteConnectionBuilder = new SqliteConnectionStringBuilder |
| | 63 | 65 | | { |
| | 63 | 66 | | DataSource = GetOption(customOptions, "path", e => e, () => Path.Combine(_applicationPaths.DataPath, "jellyf |
| | 63 | 67 | | // Private, not Default: sqlite3_enable_shared_cache is process-global, so a plugin |
| | 63 | 68 | | // enabling it makes these connections share a cache too. Contention then surfaces as |
| | 63 | 69 | | // SQLITE_LOCKED ("database table is locked"), which the busy handler does not cover, |
| | 63 | 70 | | // so busy_timeout is skipped and the command fails at CommandTimeout instead. |
| | 63 | 71 | | Cache = GetOption(customOptions, "cache", Enum.Parse<SqliteCacheMode>, () => SqliteCacheMode.Private), |
| | 63 | 72 | | Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreC |
| | 63 | 73 | | DefaultTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 60) |
| | 63 | 74 | | }; |
| | | 75 | | |
| | 63 | 76 | | var connectionString = sqliteConnectionBuilder.ToString(); |
| | | 77 | | |
| | | 78 | | // Log SQLite connection parameters |
| | 63 | 79 | | _logger.LogInformation("SQLite connection string: {ConnectionString}", connectionString); |
| | | 80 | | |
| | 63 | 81 | | options |
| | 63 | 82 | | .UseSqlite( |
| | 63 | 83 | | connectionString, |
| | 63 | 84 | | sqLiteOptions => sqLiteOptions.MigrationsAssembly(GetType().Assembly)) |
| | 63 | 85 | | // TODO: Remove when https://github.com/dotnet/efcore/pull/35873 is merged & released |
| | 63 | 86 | | .ConfigureWarnings(warnings => |
| | 63 | 87 | | warnings.Ignore(RelationalEventId.NonTransactionalMigrationOperationWarning) |
| | 63 | 88 | | .Ignore(RelationalEventId.MultipleCollectionIncludeWarning)) |
| | 63 | 89 | | .AddInterceptors(new PragmaConnectionInterceptor( |
| | 63 | 90 | | _logger, |
| | 63 | 91 | | GetOption<int?>(customOptions, "cacheSize", e => int.Parse(e, CultureInfo.InvariantCulture)), |
| | 63 | 92 | | GetOption(customOptions, "lockingmode", e => e, () => "NORMAL")!, |
| | 63 | 93 | | GetOption(customOptions, "journalsizelimit", int.Parse, () => 134_217_728), |
| | 63 | 94 | | GetOption(customOptions, "tempstoremode", int.Parse, () => 2), |
| | 63 | 95 | | GetOption(customOptions, "syncmode", int.Parse, () => 1), |
| | 63 | 96 | | customOptions?.Where(e => e.Key.StartsWith("#PRAGMA:", StringComparison.OrdinalIgnoreCase)).ToDictionary |
| | | 97 | | |
| | 63 | 98 | | var enableSensitiveDataLogging = GetOption(customOptions, "EnableSensitiveDataLogging", e => e.Equals(bool.TrueS |
| | 63 | 99 | | if (enableSensitiveDataLogging) |
| | | 100 | | { |
| | 0 | 101 | | options.EnableSensitiveDataLogging(enableSensitiveDataLogging); |
| | 0 | 102 | | _logger.LogInformation("EnableSensitiveDataLogging is enabled on SQLite connection"); |
| | | 103 | | } |
| | 63 | 104 | | } |
| | | 105 | | |
| | | 106 | | /// <inheritdoc/> |
| | | 107 | | public Task RunScheduledOptimisation(CancellationToken cancellationToken) |
| | | 108 | | { |
| | 2 | 109 | | return OptimizeAsync(cancellationToken); |
| | | 110 | | } |
| | | 111 | | |
| | | 112 | | /// <inheritdoc/> |
| | | 113 | | public async Task RefreshStatistics(CancellationToken cancellationToken) |
| | | 114 | | { |
| | 23 | 115 | | if (DbContextFactory is null) |
| | | 116 | | { |
| | 20 | 117 | | return; |
| | | 118 | | } |
| | | 119 | | |
| | 3 | 120 | | var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); |
| | 3 | 121 | | await using (context.ConfigureAwait(false)) |
| | | 122 | | { |
| | 3 | 123 | | await context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); |
| | | 124 | | try |
| | | 125 | | { |
| | 3 | 126 | | if (!await HasLibraryItemsAsync(context, cancellationToken).ConfigureAwait(false)) |
| | | 127 | | { |
| | | 128 | | return; |
| | | 129 | | } |
| | | 130 | | |
| | 2 | 131 | | _logger.LogInformation("Analyzing jellyfin.db"); |
| | 2 | 132 | | await AnalyzeAsync(context, cancellationToken).ConfigureAwait(false); |
| | | 133 | | } |
| | | 134 | | finally |
| | | 135 | | { |
| | 3 | 136 | | await context.Database.CloseConnectionAsync().ConfigureAwait(false); |
| | | 137 | | } |
| | | 138 | | } |
| | 23 | 139 | | } |
| | | 140 | | |
| | | 141 | | /// <inheritdoc/> |
| | | 142 | | public void OnModelCreating(ModelBuilder modelBuilder) |
| | | 143 | | { |
| | 4 | 144 | | modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc); |
| | 4 | 145 | | } |
| | | 146 | | |
| | | 147 | | /// <inheritdoc/> |
| | | 148 | | public async Task RunShutdownTask(CancellationToken cancellationToken) |
| | | 149 | | { |
| | | 150 | | // Run before disposing the application. Only a checkpoint: stopping is on a deadline. |
| | | 151 | | |
| | | 152 | | // Empty the pool first. Anything still parked in it can start reading again between here and the |
| | | 153 | | // checkpoint, and a reader that holds the write-ahead log open is exactly what makes the truncation |
| | | 154 | | // fail. Connections handed out already cannot be taken away, but they get disposed on return. |
| | 0 | 155 | | SqliteConnection.ClearAllPools(); |
| | | 156 | | |
| | | 157 | | try |
| | | 158 | | { |
| | 0 | 159 | | if (DbContextFactory is not null) |
| | | 160 | | { |
| | 0 | 161 | | var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); |
| | 0 | 162 | | await using (context.ConfigureAwait(false)) |
| | | 163 | | { |
| | 0 | 164 | | await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).Conf |
| | | 165 | | } |
| | | 166 | | } |
| | 0 | 167 | | } |
| | 0 | 168 | | catch (Exception ex) |
| | | 169 | | { |
| | | 170 | | // A missed checkpoint only leaves a write-ahead log for the next start to replay, so never fail the |
| | | 171 | | // shutdown over this. |
| | 0 | 172 | | _logger.LogError(ex, "Error while checkpointing jellyfin.db"); |
| | 0 | 173 | | } |
| | | 174 | | |
| | | 175 | | // The checkpointing connection went back into the pool, so retire that one as well. |
| | 0 | 176 | | SqliteConnection.ClearAllPools(); |
| | 0 | 177 | | } |
| | | 178 | | |
| | | 179 | | private async Task OptimizeAsync(CancellationToken cancellationToken) |
| | | 180 | | { |
| | 2 | 181 | | if (DbContextFactory is null) |
| | | 182 | | { |
| | 0 | 183 | | return; |
| | | 184 | | } |
| | | 185 | | |
| | 2 | 186 | | var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); |
| | 2 | 187 | | await using (context.ConfigureAwait(false)) |
| | | 188 | | { |
| | 2 | 189 | | await context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); |
| | | 190 | | try |
| | | 191 | | { |
| | | 192 | | long? tempStore; |
| | 2 | 193 | | var pragmaCommand = context.Database.GetDbConnection().CreateCommand(); |
| | 2 | 194 | | await using (pragmaCommand.ConfigureAwait(false)) |
| | | 195 | | { |
| | 2 | 196 | | pragmaCommand.CommandText = "PRAGMA temp_store"; |
| | 2 | 197 | | tempStore = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false); |
| | | 198 | | } |
| | | 199 | | |
| | 2 | 200 | | await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).Configur |
| | | 201 | | |
| | 2 | 202 | | _logger.LogDebug( |
| | 2 | 203 | | "Rebuilding jellyfin.db on disk, scratch space goes to {TempDirectory}", |
| | 2 | 204 | | Environment.GetEnvironmentVariable("SQLITE_TMPDIR") ?? "SQLite's default temporary directory"); |
| | 2 | 205 | | await context.Database.ExecuteSqlRawAsync("PRAGMA temp_store=1", cancellationToken).ConfigureAwait(false |
| | | 206 | | try |
| | | 207 | | { |
| | 2 | 208 | | await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); |
| | | 209 | | } |
| | | 210 | | finally |
| | | 211 | | { |
| | | 212 | | // The connection goes back to the pool, so hand it over the way it was handed to us. |
| | 2 | 213 | | if (tempStore is not null) |
| | | 214 | | { |
| | 2 | 215 | | await context.Database.ExecuteSqlRawAsync( |
| | 2 | 216 | | FormattableString.Invariant($"PRAGMA temp_store={tempStore.Value}"), |
| | 2 | 217 | | CancellationToken.None).ConfigureAwait(false); |
| | | 218 | | } |
| | | 219 | | } |
| | | 220 | | |
| | | 221 | | // Statistics taken while the library is empty make the planner treat every table as one row and |
| | | 222 | | // pick full scans once it fills up; no statistics at all plan far better until there is data. |
| | 2 | 223 | | if (await HasLibraryItemsAsync(context, cancellationToken).ConfigureAwait(false)) |
| | | 224 | | { |
| | 1 | 225 | | await AnalyzeAsync(context, cancellationToken).ConfigureAwait(false); |
| | | 226 | | } |
| | | 227 | | else |
| | | 228 | | { |
| | 1 | 229 | | _logger.LogInformation("Not analyzing jellyfin.db, the library holds no items yet"); |
| | | 230 | | } |
| | | 231 | | |
| | 2 | 232 | | await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).Configur |
| | 2 | 233 | | _logger.LogInformation("jellyfin.db optimized successfully!"); |
| | | 234 | | } |
| | | 235 | | finally |
| | | 236 | | { |
| | 2 | 237 | | await context.Database.CloseConnectionAsync().ConfigureAwait(false); |
| | | 238 | | } |
| | | 239 | | } |
| | 2 | 240 | | } |
| | | 241 | | |
| | | 242 | | private static Task<bool> HasLibraryItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken) |
| | | 243 | | { |
| | | 244 | | // Folders and the seeded placeholder exist before any library has been scanned. |
| | 5 | 245 | | return context.BaseItems.AnyAsync(e => !e.IsFolder && e.Type != "PLACEHOLDER", cancellationToken); |
| | | 246 | | } |
| | | 247 | | |
| | | 248 | | private static async Task AnalyzeAsync(JellyfinDbContext context, CancellationToken cancellationToken) |
| | | 249 | | { |
| | | 250 | | long? analysisLimit; |
| | 3 | 251 | | var pragmaCommand = context.Database.GetDbConnection().CreateCommand(); |
| | 3 | 252 | | await using (pragmaCommand.ConfigureAwait(false)) |
| | | 253 | | { |
| | 3 | 254 | | pragmaCommand.CommandText = "PRAGMA analysis_limit"; |
| | 3 | 255 | | analysisLimit = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false); |
| | | 256 | | } |
| | | 257 | | |
| | 3 | 258 | | await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false); |
| | | 259 | | try |
| | | 260 | | { |
| | 3 | 261 | | await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false); |
| | | 262 | | } |
| | | 263 | | finally |
| | | 264 | | { |
| | | 265 | | // The connection goes back to the pool, so hand it over the way it was handed to us. |
| | 3 | 266 | | if (analysisLimit is not null) |
| | | 267 | | { |
| | 3 | 268 | | await context.Database.ExecuteSqlRawAsync( |
| | 3 | 269 | | FormattableString.Invariant($"PRAGMA analysis_limit={analysisLimit.Value}"), |
| | 3 | 270 | | CancellationToken.None).ConfigureAwait(false); |
| | | 271 | | } |
| | | 272 | | } |
| | 3 | 273 | | } |
| | | 274 | | |
| | | 275 | | private static async Task<long?> ReadPragmaValueAsync(DbCommand command, CancellationToken cancellationToken) |
| | | 276 | | { |
| | 5 | 277 | | var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); |
| | 5 | 278 | | return value is null or DBNull ? null : Convert.ToInt64(value, CultureInfo.InvariantCulture); |
| | 5 | 279 | | } |
| | | 280 | | |
| | | 281 | | /// <inheritdoc/> |
| | | 282 | | public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) |
| | | 283 | | { |
| | 4 | 284 | | configurationBuilder.Conventions.Add(_ => new DoNotUseReturningClauseConvention()); |
| | 4 | 285 | | } |
| | | 286 | | |
| | | 287 | | /// <inheritdoc /> |
| | | 288 | | public async Task<string> MigrationBackupFast(CancellationToken cancellationToken) |
| | | 289 | | { |
| | 0 | 290 | | var path = GetDatabasePath(); |
| | 0 | 291 | | var backupFolder = Path.Combine(_applicationPaths.DataPath, BackupFolderName); |
| | 0 | 292 | | Directory.CreateDirectory(backupFolder); |
| | | 293 | | |
| | 0 | 294 | | if (DbContextFactory is not null && File.Exists(path)) |
| | | 295 | | { |
| | 0 | 296 | | var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); |
| | 0 | 297 | | await using (context.ConfigureAwait(false)) |
| | | 298 | | { |
| | 0 | 299 | | await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).Configur |
| | | 300 | | } |
| | | 301 | | } |
| | | 302 | | |
| | 0 | 303 | | var key = DateTime.UtcNow.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture); |
| | 0 | 304 | | var backupFile = Path.Combine(backupFolder, $"{key}_jellyfin.db"); |
| | 0 | 305 | | for (var attempt = 1; File.Exists(backupFile); attempt++) |
| | | 306 | | { |
| | 0 | 307 | | key = string.Create(CultureInfo.InvariantCulture, $"{DateTime.UtcNow:yyyyMMddHHmmss}_{attempt}"); |
| | 0 | 308 | | backupFile = Path.Combine(backupFolder, $"{key}_jellyfin.db"); |
| | | 309 | | } |
| | | 310 | | |
| | 0 | 311 | | File.Copy(path, backupFile); |
| | 0 | 312 | | return key; |
| | 0 | 313 | | } |
| | | 314 | | |
| | | 315 | | /// <inheritdoc /> |
| | | 316 | | public Task RestoreBackupFast(string key, CancellationToken cancellationToken) |
| | | 317 | | { |
| | | 318 | | // ensure there are absolutely no dangling Sqlite connections. |
| | 0 | 319 | | SqliteConnection.ClearAllPools(); |
| | 0 | 320 | | var path = GetDatabasePath(); |
| | 0 | 321 | | var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName, $"{key}_jellyfin.db"); |
| | | 322 | | |
| | 0 | 323 | | if (!File.Exists(backupFile)) |
| | | 324 | | { |
| | 0 | 325 | | _logger.LogCritical("Tried to restore a backup that does not exist: {Key}", key); |
| | 0 | 326 | | return Task.CompletedTask; |
| | | 327 | | } |
| | | 328 | | |
| | 0 | 329 | | if (!TryRetireWriteAheadLog(path)) |
| | | 330 | | { |
| | 0 | 331 | | _logger.LogCritical( |
| | 0 | 332 | | "Refusing to restore jellyfin.db: the write-ahead log at {WriteAheadLog} could not be retired, which " |
| | 0 | 333 | | + "means the database is still open and replacing it now would silently bring back the data this " |
| | 0 | 334 | | + "rollback is undoing. Stop the server and copy {Backup} over {Path} by hand.", |
| | 0 | 335 | | path + "-wal", |
| | 0 | 336 | | backupFile, |
| | 0 | 337 | | path); |
| | 0 | 338 | | return Task.CompletedTask; |
| | | 339 | | } |
| | | 340 | | |
| | 0 | 341 | | File.Copy(backupFile, path, true); |
| | | 342 | | |
| | 0 | 343 | | return Task.CompletedTask; |
| | | 344 | | } |
| | | 345 | | |
| | | 346 | | private bool TryRetireWriteAheadLog(string path) |
| | | 347 | | { |
| | 0 | 348 | | var writeAheadLogPath = path + "-wal"; |
| | 0 | 349 | | if (!File.Exists(path) || !File.Exists(writeAheadLogPath)) |
| | | 350 | | { |
| | 0 | 351 | | return true; |
| | | 352 | | } |
| | | 353 | | |
| | | 354 | | try |
| | | 355 | | { |
| | 0 | 356 | | var connectionString = new SqliteConnectionStringBuilder |
| | 0 | 357 | | { |
| | 0 | 358 | | DataSource = path, |
| | 0 | 359 | | Mode = SqliteOpenMode.ReadWrite, |
| | 0 | 360 | | Pooling = false |
| | 0 | 361 | | }.ToString(); |
| | | 362 | | |
| | 0 | 363 | | using var connection = new SqliteConnection(connectionString); |
| | 0 | 364 | | connection.Open(); |
| | 0 | 365 | | using var command = connection.CreateCommand(); |
| | 0 | 366 | | command.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; |
| | 0 | 367 | | command.ExecuteNonQuery(); |
| | 0 | 368 | | } |
| | 0 | 369 | | catch (SqliteException ex) |
| | | 370 | | { |
| | | 371 | | // Either something else holds the database or it is too damaged to open. The check below covers both. |
| | 0 | 372 | | _logger.LogError(ex, "Could not open jellyfin.db to retire its write-ahead log"); |
| | 0 | 373 | | } |
| | | 374 | | |
| | 0 | 375 | | return !File.Exists(writeAheadLogPath); |
| | | 376 | | } |
| | | 377 | | |
| | | 378 | | /// <inheritdoc /> |
| | | 379 | | public Task DeleteBackup(string key) |
| | | 380 | | { |
| | 0 | 381 | | var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName, $"{key}_jellyfin.db"); |
| | | 382 | | |
| | 0 | 383 | | if (!File.Exists(backupFile)) |
| | | 384 | | { |
| | 0 | 385 | | _logger.LogCritical("Tried to delete a backup that does not exist: {Key}", key); |
| | 0 | 386 | | return Task.CompletedTask; |
| | | 387 | | } |
| | | 388 | | |
| | 0 | 389 | | File.Delete(backupFile); |
| | 0 | 390 | | return Task.CompletedTask; |
| | | 391 | | } |
| | | 392 | | |
| | | 393 | | private string GetDatabasePath() |
| | | 394 | | { |
| | 0 | 395 | | if (DbContextFactory is null) |
| | | 396 | | { |
| | 0 | 397 | | throw new InvalidOperationException("The database provider has not been initialized."); |
| | | 398 | | } |
| | | 399 | | |
| | 0 | 400 | | using var dbContext = DbContextFactory.CreateDbContext(); |
| | 0 | 401 | | return dbContext.Database.GetDbConnection().DataSource; |
| | 0 | 402 | | } |
| | | 403 | | |
| | | 404 | | /// <inheritdoc/> |
| | | 405 | | public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames) |
| | | 406 | | { |
| | 0 | 407 | | ArgumentNullException.ThrowIfNull(tableNames); |
| | | 408 | | |
| | 0 | 409 | | var deleteQueries = new List<string>(); |
| | 0 | 410 | | foreach (var tableName in tableNames) |
| | | 411 | | { |
| | 0 | 412 | | deleteQueries.Add($"DELETE FROM \"{tableName}\";"); |
| | | 413 | | } |
| | | 414 | | |
| | 0 | 415 | | var deleteAllQuery = |
| | 0 | 416 | | $""" |
| | 0 | 417 | | PRAGMA foreign_keys = OFF; |
| | 0 | 418 | | {string.Join('\n', deleteQueries)} |
| | 0 | 419 | | PRAGMA foreign_keys = ON; |
| | 0 | 420 | | """; |
| | | 421 | | |
| | 0 | 422 | | await dbContext.Database.ExecuteSqlRawAsync(deleteAllQuery).ConfigureAwait(false); |
| | 0 | 423 | | } |
| | | 424 | | } |