Browse Source

Add declarative backups for migrations (#14135)

JPVenson 2 days ago
parent
commit
d5672ce407

+ 2 - 0
Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs

@@ -10,4 +10,6 @@ internal class BackupOptions
     public bool Trickplay { get; set; }
 
     public bool Subtitles { get; set; }
+
+    public bool Database { get; set; }
 }

+ 81 - 71
Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs

@@ -16,6 +16,7 @@ using MediaBrowser.Controller.SystemBackupService;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.EntityFrameworkCore.Infrastructure;
 using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
 
 namespace Jellyfin.Server.Implementations.FullSystemBackup;
@@ -31,7 +32,7 @@ public class BackupService : IBackupService
     private readonly IServerApplicationHost _applicationHost;
     private readonly IServerApplicationPaths _applicationPaths;
     private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
-    private readonly ISystemManager _systemManager;
+    private readonly IHostApplicationLifetime _hostApplicationLifetime;
     private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
     {
         AllowTrailingCommas = true,
@@ -48,21 +49,21 @@ public class BackupService : IBackupService
     /// <param name="applicationHost">The Application host.</param>
     /// <param name="applicationPaths">The application paths.</param>
     /// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
-    /// <param name="systemManager">The SystemManager.</param>
+    /// <param name="applicationLifetime">The SystemManager.</param>
     public BackupService(
         ILogger<BackupService> logger,
         IDbContextFactory<JellyfinDbContext> dbProvider,
         IServerApplicationHost applicationHost,
         IServerApplicationPaths applicationPaths,
         IJellyfinDatabaseProvider jellyfinDatabaseProvider,
-        ISystemManager systemManager)
+        IHostApplicationLifetime applicationLifetime)
     {
         _logger = logger;
         _dbProvider = dbProvider;
         _applicationHost = applicationHost;
         _applicationPaths = applicationPaths;
         _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
-        _systemManager = systemManager;
+        _hostApplicationLifetime = applicationLifetime;
     }
 
     /// <inheritdoc/>
@@ -71,7 +72,11 @@ public class BackupService : IBackupService
         _applicationHost.RestoreBackupPath = archivePath;
         _applicationHost.ShouldRestart = true;
         _applicationHost.NotifyPendingRestart();
-        _systemManager.Restart();
+        _ = Task.Run(async () =>
+        {
+            await Task.Delay(500).ConfigureAwait(false);
+            _hostApplicationLifetime.StopApplication();
+        });
     }
 
     /// <inheritdoc/>
@@ -136,87 +141,90 @@ public class BackupService : IBackupService
             CopyDirectory(_applicationPaths.DataPath, "Data/");
             CopyDirectory(_applicationPaths.RootFolderPath, "Root/");
 
-            _logger.LogInformation("Begin restoring Database");
-            var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
-            await using (dbContext.ConfigureAwait(false))
+            if (manifest.Options.Database)
             {
-                // restore migration history manually
-                var historyEntry = zipArchive.GetEntry($"Database\\{nameof(HistoryRow)}.json");
-                if (historyEntry is null)
-                {
-                    _logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation");
-                    throw new InvalidOperationException("Cannot restore backup that has no History data.");
-                }
-
-                HistoryRow[] historyEntries;
-                var historyArchive = historyEntry.Open();
-                await using (historyArchive.ConfigureAwait(false))
+                _logger.LogInformation("Begin restoring Database");
+                var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+                await using (dbContext.ConfigureAwait(false))
                 {
-                    historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ??
+                    // restore migration history manually
+                    var historyEntry = zipArchive.GetEntry($"Database\\{nameof(HistoryRow)}.json");
+                    if (historyEntry is null)
+                    {
+                        _logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation");
                         throw new InvalidOperationException("Cannot restore backup that has no History data.");
-                }
+                    }
 
-                var historyRepository = dbContext.GetService<IHistoryRepository>();
-                await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
-                foreach (var item in historyEntries)
-                {
-                    var insertScript = historyRepository.GetInsertScript(item);
-                    await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
-                }
+                    HistoryRow[] historyEntries;
+                    var historyArchive = historyEntry.Open();
+                    await using (historyArchive.ConfigureAwait(false))
+                    {
+                        historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ??
+                            throw new InvalidOperationException("Cannot restore backup that has no History data.");
+                    }
 
-                dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
-                var entityTypes = typeof(JellyfinDbContext).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
-                    .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
-                    .Select(e => (Type: e, Set: e.GetValue(dbContext) as IQueryable))
-                    .ToArray();
+                    var historyRepository = dbContext.GetService<IHistoryRepository>();
+                    await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
+                    foreach (var item in historyEntries)
+                    {
+                        var insertScript = historyRepository.GetInsertScript(item);
+                        await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
+                    }
 
-                var tableNames = entityTypes.Select(f => dbContext.Model.FindEntityType(f.Type.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!);
-                _logger.LogInformation("Begin purging database");
-                await _jellyfinDatabaseProvider.PurgeDatabase(dbContext, tableNames).ConfigureAwait(false);
-                _logger.LogInformation("Database Purged");
+                    dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
+                    var entityTypes = typeof(JellyfinDbContext).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
+                        .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
+                        .Select(e => (Type: e, Set: e.GetValue(dbContext) as IQueryable))
+                        .ToArray();
 
-                foreach (var entityType in entityTypes)
-                {
-                    _logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
+                    var tableNames = entityTypes.Select(f => dbContext.Model.FindEntityType(f.Type.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!);
+                    _logger.LogInformation("Begin purging database");
+                    await _jellyfinDatabaseProvider.PurgeDatabase(dbContext, tableNames).ConfigureAwait(false);
+                    _logger.LogInformation("Database Purged");
 
-                    var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json");
-                    if (zipEntry is null)
+                    foreach (var entityType in entityTypes)
                     {
-                        _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
-                        continue;
-                    }
+                        _logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
 
-                    var zipEntryStream = zipEntry.Open();
-                    await using (zipEntryStream.ConfigureAwait(false))
-                    {
-                        _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
-                        var records = 0;
-                        await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false)!)
+                        var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json");
+                        if (zipEntry is null)
                         {
-                            var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
-                            if (entity is null)
-                            {
-                                throw new InvalidOperationException($"Cannot deserialize entity '{item}'");
-                            }
+                            _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
+                            continue;
+                        }
 
-                            try
-                            {
-                                records++;
-                                dbContext.Add(entity);
-                            }
-                            catch (Exception ex)
+                        var zipEntryStream = zipEntry.Open();
+                        await using (zipEntryStream.ConfigureAwait(false))
+                        {
+                            _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
+                            var records = 0;
+                            await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false)!)
                             {
-                                _logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item);
+                                var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
+                                if (entity is null)
+                                {
+                                    throw new InvalidOperationException($"Cannot deserialize entity '{item}'");
+                                }
+
+                                try
+                                {
+                                    records++;
+                                    dbContext.Add(entity);
+                                }
+                                catch (Exception ex)
+                                {
+                                    _logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item);
+                                }
                             }
-                        }
 
-                        _logger.LogInformation("Prepared to restore {Number} entries for {Table}", records, entityType.Type.Name);
+                            _logger.LogInformation("Prepared to restore {Number} entries for {Table}", records, entityType.Type.Name);
+                        }
                     }
-                }
 
-                _logger.LogInformation("Try restore Database");
-                await dbContext.SaveChangesAsync().ConfigureAwait(false);
-                _logger.LogInformation("Restored database.");
+                    _logger.LogInformation("Try restore Database");
+                    await dbContext.SaveChangesAsync().ConfigureAwait(false);
+                    _logger.LogInformation("Restored database.");
+                }
             }
 
             _logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated);
@@ -486,7 +494,8 @@ public class BackupService : IBackupService
         {
             Metadata = options.Metadata,
             Subtitles = options.Subtitles,
-            Trickplay = options.Trickplay
+            Trickplay = options.Trickplay,
+            Database = options.Database
         };
     }
 
@@ -496,7 +505,8 @@ public class BackupService : IBackupService
         {
             Metadata = options.Metadata,
             Subtitles = options.Subtitles,
-            Trickplay = options.Trickplay
+            Trickplay = options.Trickplay,
+            Database = options.Database
         };
     }
 }

+ 2 - 2
Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs

@@ -47,9 +47,9 @@ public sealed class JellyfinMigrationAttribute : Attribute
     public bool RunMigrationOnSetup { get; set; }
 
     /// <summary>
-    /// Gets or Sets the stage the annoated migration should be executed at. Defaults to <see cref="JellyfinMigrationStageTypes.CoreInitialisaition"/>.
+    /// Gets or Sets the stage the annoated migration should be executed at. Defaults to <see cref="JellyfinMigrationStageTypes.CoreInitialisation"/>.
     /// </summary>
-    public JellyfinMigrationStageTypes Stage { get; set; } = JellyfinMigrationStageTypes.CoreInitialisaition;
+    public JellyfinMigrationStageTypes Stage { get; set; } = JellyfinMigrationStageTypes.CoreInitialisation;
 
     /// <summary>
     /// Gets the ordering of the migration.

+ 35 - 0
Jellyfin.Server/Migrations/JellyfinMigrationBackupAttribute.cs

@@ -0,0 +1,35 @@
+using System;
+
+namespace Jellyfin.Server.Migrations;
+
+/// <summary>
+/// Marks an <see cref="JellyfinMigrationAttribute"/> migration and instructs the <see cref="JellyfinMigrationService"/> to perform a backup.
+/// </summary>
+[AttributeUsage(System.AttributeTargets.Class, Inherited = true, AllowMultiple = true)]
+public sealed class JellyfinMigrationBackupAttribute : System.Attribute
+{
+    /// <summary>
+    /// Gets or Sets a value indicating whether a backup of the old library.db should be performed.
+    /// </summary>
+    public bool LegacyLibraryDb { get; set; }
+
+    /// <summary>
+    /// Gets or Sets a value indicating whether a backup of the Database should be performed.
+    /// </summary>
+    public bool JellyfinDb { get; set; }
+
+    /// <summary>
+    /// Gets or Sets a value indicating whether a backup of the metadata folder should be performed.
+    /// </summary>
+    public bool Metadata { get; set; }
+
+    /// <summary>
+    /// Gets or Sets a value indicating whether a backup of the Trickplay folder should be performed.
+    /// </summary>
+    public bool Trickplay { get; set; }
+
+    /// <summary>
+    /// Gets or Sets a value indicating whether a backup of the Subtitles folder should be performed.
+    /// </summary>
+    public bool Subtitles { get; set; }
+}

+ 209 - 5
Jellyfin.Server/Migrations/JellyfinMigrationService.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Reflection;
@@ -25,21 +26,37 @@ namespace Jellyfin.Server.Migrations;
 /// </summary>
 internal class JellyfinMigrationService
 {
+    private const string DbFilename = "library.db";
     private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
     private readonly ILoggerFactory _loggerFactory;
+    private readonly IBackupService? _backupService;
+    private readonly IJellyfinDatabaseProvider? _jellyfinDatabaseProvider;
+    private readonly IApplicationPaths _applicationPaths;
+    private (string? LibraryDb, string? JellyfinDb, BackupManifestDto? FullBackup) _backupKey;
 
     /// <summary>
     /// Initializes a new instance of the <see cref="JellyfinMigrationService"/> class.
     /// </summary>
     /// <param name="dbContextFactory">Provides access to the jellyfin database.</param>
     /// <param name="loggerFactory">The logger factory.</param>
-    public JellyfinMigrationService(IDbContextFactory<JellyfinDbContext> dbContextFactory, ILoggerFactory loggerFactory)
+    /// <param name="applicationPaths">Application paths for library.db backup.</param>
+    /// <param name="backupService">The jellyfin backup service.</param>
+    /// <param name="jellyfinDatabaseProvider">The jellyfin database provider.</param>
+    public JellyfinMigrationService(
+        IDbContextFactory<JellyfinDbContext> dbContextFactory,
+        ILoggerFactory loggerFactory,
+        IApplicationPaths applicationPaths,
+        IBackupService? backupService = null,
+        IJellyfinDatabaseProvider? jellyfinDatabaseProvider = null)
     {
         _dbContextFactory = dbContextFactory;
         _loggerFactory = loggerFactory;
+        _backupService = backupService;
+        _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
+        _applicationPaths = applicationPaths;
 #pragma warning disable CS0618 // Type or member is obsolete
         Migrations = [.. typeof(IMigrationRoutine).Assembly.GetTypes().Where(e => typeof(IMigrationRoutine).IsAssignableFrom(e) || typeof(IAsyncMigrationRoutine).IsAssignableFrom(e))
-            .Select(e => (Type: e, Metadata: e.GetCustomAttribute<JellyfinMigrationAttribute>()))
+            .Select(e => (Type: e, Metadata: e.GetCustomAttribute<JellyfinMigrationAttribute>(), Backup: e.GetCustomAttributes<JellyfinMigrationBackupAttribute>()))
             .Where(e => e.Metadata != null)
             .GroupBy(e => e.Metadata!.Stage)
             .Select(f =>
@@ -47,7 +64,13 @@ internal class JellyfinMigrationService
                 var stage = new MigrationStage(f.Key);
                 foreach (var item in f)
                 {
-                    stage.Add(new(item.Type, item.Metadata!));
+                    JellyfinMigrationBackupAttribute? backupMetadata = null;
+                    if (item.Backup?.Any() == true)
+                    {
+                        backupMetadata = item.Backup.Aggregate(MergeBackupAttributes);
+                    }
+
+                    stage.Add(new(item.Type, item.Metadata!, backupMetadata));
                 }
 
                 return stage;
@@ -155,7 +178,7 @@ internal class JellyfinMigrationService
                 .ToArray();
 
             (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = [];
-            if (stage is JellyfinMigrationStageTypes.CoreInitialisaition)
+            if (stage is JellyfinMigrationStageTypes.CoreInitialisation)
             {
                 pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key))
                    .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext)))
@@ -176,7 +199,51 @@ internal class JellyfinMigrationService
                 }
                 catch (Exception ex)
                 {
-                    logger.LogCritical(ex, "Migration {Name} failed", item.Key);
+                    logger.LogCritical(ex, "Migration {Name} failed, migration service will attempt to roll back.", item.Key);
+
+                    if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
+                    {
+                        if (_backupKey.LibraryDb is not null)
+                        {
+                            logger.LogInformation("Attempt to rollback librarydb.");
+                            try
+                            {
+                                var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
+                                File.Move(_backupKey.LibraryDb, libraryDbPath, true);
+                            }
+                            catch (Exception inner)
+                            {
+                                logger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
+                            }
+                        }
+
+                        if (_backupKey.JellyfinDb is not null)
+                        {
+                            logger.LogInformation("Attempt to rollback JellyfinDb.");
+                            try
+                            {
+                                await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
+                            }
+                            catch (Exception inner)
+                            {
+                                logger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
+                            }
+                        }
+
+                        if (_backupKey.FullBackup is not null)
+                        {
+                            logger.LogInformation("Attempt to rollback from backup.");
+                            try
+                            {
+                                await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
+                            }
+                            catch (Exception inner)
+                            {
+                                logger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
+                            }
+                        }
+                    }
+
                     throw;
                 }
             }
@@ -188,6 +255,143 @@ internal class JellyfinMigrationService
         return Assembly.GetEntryAssembly()!.GetName().Version!.ToString();
     }
 
+    public async Task CleanupSystemAfterMigration(ILogger logger)
+    {
+        if (_backupKey != default)
+        {
+            if (_backupKey.LibraryDb is not null)
+            {
+                logger.LogInformation("Attempt to cleanup librarydb backup.");
+                try
+                {
+                    File.Delete(_backupKey.LibraryDb);
+                }
+                catch (Exception inner)
+                {
+                    logger.LogCritical(inner, "Could not cleanup {LibraryPath}.", _backupKey.LibraryDb);
+                }
+            }
+
+            if (_backupKey.JellyfinDb is not null && _jellyfinDatabaseProvider is not null)
+            {
+                logger.LogInformation("Attempt to cleanup JellyfinDb backup.");
+                try
+                {
+                    await _jellyfinDatabaseProvider.DeleteBackup(_backupKey.JellyfinDb).ConfigureAwait(false);
+                }
+                catch (Exception inner)
+                {
+                    logger.LogCritical(inner, "Could not cleanup {LibraryPath}.", _backupKey.JellyfinDb);
+                }
+            }
+
+            if (_backupKey.FullBackup is not null)
+            {
+                logger.LogInformation("Attempt to cleanup from migration backup.");
+                try
+                {
+                    File.Delete(_backupKey.FullBackup.Path);
+                }
+                catch (Exception inner)
+                {
+                    logger.LogCritical(inner, "Could not cleanup backup {Backup}.", _backupKey.FullBackup.Path);
+                }
+            }
+        }
+    }
+
+    public async Task PrepareSystemForMigration(ILogger logger)
+    {
+        logger.LogInformation("Prepare system for possible migrations");
+        JellyfinMigrationBackupAttribute backupInstruction;
+        IReadOnlyList<HistoryRow> appliedMigrations;
+        var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+        await using (dbContext.ConfigureAwait(false))
+        {
+            var historyRepository = dbContext.GetService<IHistoryRepository>();
+            var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
+            appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
+            backupInstruction = new JellyfinMigrationBackupAttribute()
+            {
+                JellyfinDb = migrationsAssembly.Migrations.Any(f => appliedMigrations.All(e => e.MigrationId != f.Key))
+            };
+        }
+
+        backupInstruction = Migrations.SelectMany(e => e)
+           .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
+           .Select(e => e.BackupRequirements)
+           .Where(e => e is not null)
+           .Aggregate(backupInstruction, MergeBackupAttributes!);
+
+        if (backupInstruction.LegacyLibraryDb)
+        {
+            logger.LogInformation("A migration will attempt to modify the library.db, will attempt to backup the file now.");
+            // for legacy migrations that still operates on the library.db
+            var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
+            if (File.Exists(libraryDbPath))
+            {
+                for (int i = 1; ; i++)
+                {
+                    var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", libraryDbPath, i);
+                    if (!File.Exists(bakPath))
+                    {
+                        try
+                        {
+                            logger.LogInformation("Backing up {Library} to {BackupPath}", DbFilename, bakPath);
+                            File.Copy(libraryDbPath, bakPath);
+                            _backupKey = (bakPath, _backupKey.JellyfinDb, _backupKey.FullBackup);
+                            logger.LogInformation("{Library} backed up to {BackupPath}", DbFilename, bakPath);
+                            break;
+                        }
+                        catch (Exception ex)
+                        {
+                            logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
+                            throw;
+                        }
+                    }
+                }
+
+                logger.LogInformation("{Library} has been backed up as {BackupPath}", DbFilename, _backupKey.LibraryDb);
+            }
+            else
+            {
+                logger.LogError("Cannot make a backup of {Library} at path {BackupPath} because file could not be found at {LibraryPath}", DbFilename, libraryDbPath, _applicationPaths.DataPath);
+            }
+        }
+
+        if (backupInstruction.JellyfinDb && _jellyfinDatabaseProvider != null)
+        {
+            logger.LogInformation("A migration will attempt to modify the jellyfin.db, will attempt to backup the file now.");
+            _backupKey = (_backupKey.LibraryDb, await _jellyfinDatabaseProvider.MigrationBackupFast(CancellationToken.None).ConfigureAwait(false), _backupKey.FullBackup);
+            logger.LogInformation("Jellyfin database has been backed up as {BackupPath}", _backupKey.JellyfinDb);
+        }
+
+        if (_backupService is not null && (backupInstruction.Metadata || backupInstruction.Subtitles || backupInstruction.Trickplay))
+        {
+            logger.LogInformation("A migration will attempt to modify system resources. Will attempt to create backup now.");
+            _backupKey = (_backupKey.LibraryDb, _backupKey.JellyfinDb, await _backupService.CreateBackupAsync(new BackupOptionsDto()
+            {
+                Metadata = backupInstruction.Metadata,
+                Subtitles = backupInstruction.Subtitles,
+                Trickplay = backupInstruction.Trickplay,
+                Database = false // database backups are explicitly handled by the provider itself as the backup service requires parity with the current model
+            }).ConfigureAwait(false));
+            logger.LogInformation("Pre-Migration backup successfully created as {BackupKey}", _backupKey.FullBackup.Path);
+        }
+    }
+
+    private static JellyfinMigrationBackupAttribute MergeBackupAttributes(JellyfinMigrationBackupAttribute left, JellyfinMigrationBackupAttribute right)
+    {
+        return new JellyfinMigrationBackupAttribute()
+        {
+            JellyfinDb = left!.JellyfinDb || right!.JellyfinDb,
+            LegacyLibraryDb = left.LegacyLibraryDb || right!.LegacyLibraryDb,
+            Metadata = left.Metadata || right!.Metadata,
+            Subtitles = left.Subtitles || right!.Subtitles,
+            Trickplay = left.Trickplay || right!.Trickplay
+        };
+    }
+
     private class InternalCodeMigration : IInternalMigration
     {
         private readonly CodeMigration _codeMigration;

+ 1 - 24
Jellyfin.Server/Migrations/Routines/FixAudioData.cs

@@ -18,10 +18,10 @@ namespace Jellyfin.Server.Migrations.Routines
     /// </summary>
 #pragma warning disable CS0618 // Type or member is obsolete
     [JellyfinMigration("2025-04-20T18:00:00", nameof(FixAudioData), "CF6FABC2-9FBE-4933-84A5-FFE52EF22A58")]
+    [JellyfinMigrationBackup(LegacyLibraryDb = true)]
     internal class FixAudioData : IMigrationRoutine
 #pragma warning restore CS0618 // Type or member is obsolete
     {
-        private const string DbFilename = "library.db";
         private readonly ILogger<FixAudioData> _logger;
         private readonly IServerApplicationPaths _applicationPaths;
         private readonly IItemRepository _itemRepository;
@@ -39,29 +39,6 @@ namespace Jellyfin.Server.Migrations.Routines
         /// <inheritdoc/>
         public void Perform()
         {
-            var dbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
-
-            // Back up the database before modifying any entries
-            for (int i = 1; ; i++)
-            {
-                var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
-                if (!File.Exists(bakPath))
-                {
-                    try
-                    {
-                        _logger.LogInformation("Backing up {Library} to {BackupPath}", DbFilename, bakPath);
-                        File.Copy(dbPath, bakPath);
-                        _logger.LogInformation("{Library} backed up to {BackupPath}", DbFilename, bakPath);
-                        break;
-                    }
-                    catch (Exception ex)
-                    {
-                        _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
-                        throw;
-                    }
-                }
-            }
-
             _logger.LogInformation("Backfilling audio lyrics data to database.");
             var startIndex = 0;
             var records = _itemRepository.GetCount(new InternalItemsQuery

+ 1 - 0
Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs

@@ -29,6 +29,7 @@ namespace Jellyfin.Server.Migrations.Routines;
 /// The migration routine for migrating the userdata database to EF Core.
 /// </summary>
 [JellyfinMigration("2025-04-20T20:00:00", nameof(MigrateLibraryDb))]
+[JellyfinMigrationBackup(JellyfinDb = true, LegacyLibraryDb = true)]
 internal class MigrateLibraryDb : IDatabaseMigrationRoutine
 {
     private const string DbFilename = "library.db";

+ 1 - 0
Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs

@@ -12,6 +12,7 @@ namespace Jellyfin.Server.Migrations.Routines;
 /// </summary>
 #pragma warning disable CS0618 // Type or member is obsolete
 [JellyfinMigration("2025-04-20T22:00:00", nameof(MigrateRatingLevels))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
 #pragma warning restore CS0618 // Type or member is obsolete
 internal class MigrateRatingLevels : IDatabaseMigrationRoutine
 {

+ 3 - 1
Jellyfin.Server/Migrations/Stages/CodeMigration.cs

@@ -6,12 +6,14 @@ using Microsoft.Extensions.DependencyInjection;
 
 namespace Jellyfin.Server.Migrations.Stages;
 
-internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute metadata)
+internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute metadata, JellyfinMigrationBackupAttribute? migrationBackupAttribute)
 {
     public Type MigrationType { get; } = migrationType;
 
     public JellyfinMigrationAttribute Metadata { get; } = metadata;
 
+    public JellyfinMigrationBackupAttribute? BackupRequirements { get; set; } = migrationBackupAttribute;
+
     public string BuildCodeMigrationId()
     {
         return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + MigrationType.Name!;

+ 1 - 1
Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs

@@ -17,7 +17,7 @@ public enum JellyfinMigrationStageTypes
     /// Runs after the host has been configured and includes the database migrations.
     /// Allows the mix order of migrations that contain application code and database changes.
     /// </summary>
-    CoreInitialisaition = 2,
+    CoreInitialisation = 2,
 
     /// <summary>
     /// Runs after services has been registered and initialised. Last step before running the server.

+ 6 - 4
Jellyfin.Server/Program.cs

@@ -16,10 +16,10 @@ using Jellyfin.Server.Extensions;
 using Jellyfin.Server.Helpers;
 using Jellyfin.Server.Implementations.DatabaseConfiguration;
 using Jellyfin.Server.Implementations.Extensions;
-using Jellyfin.Server.Implementations.FullSystemBackup;
 using Jellyfin.Server.Implementations.StorageHelpers;
 using Jellyfin.Server.Implementations.SystemBackupService;
 using Jellyfin.Server.Migrations;
+using Jellyfin.Server.Migrations.Stages;
 using Jellyfin.Server.ServerSetupApp;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Net;
@@ -190,12 +190,14 @@ namespace Jellyfin.Server
                     return;
                 }
 
-                await ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.CoreInitialisaition).ConfigureAwait(false);
+                var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider);
+                await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
+                await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
 
                 await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
 
-                await ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.AppInitialisation).ConfigureAwait(false);
-
+                await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
+                await jellyfinMigrationService.CleanupSystemAfterMigration(_logger).ConfigureAwait(false);
                 try
                 {
                     await _setupServer!.StopAsync().ConfigureAwait(false);

+ 5 - 0
MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs

@@ -21,4 +21,9 @@ public class BackupOptionsDto
     /// Gets or sets a value indicating whether the archive contains the Subtitle contents.
     /// </summary>
     public bool Subtitles { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether the archive contains the Database contents.
+    /// </summary>
+    public bool Database { get; set; } = true;
 }

+ 7 - 0
src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs

@@ -64,6 +64,13 @@ public interface IJellyfinDatabaseProvider
     /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
     Task RestoreBackupFast(string key, CancellationToken cancellationToken);
 
+    /// <summary>
+    /// Deletes a backup that has been previously created by <see cref="MigrationBackupFast(CancellationToken)"/>.
+    /// </summary>
+    /// <param name="key">The key to the backup which should be cleaned up.</param>
+    /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
+    Task DeleteBackup(string key);
+
     /// <summary>
     /// Removes all contents from the database.
     /// </summary>

+ 15 - 0
src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs

@@ -129,6 +129,21 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
         return Task.CompletedTask;
     }
 
+    /// <inheritdoc />
+    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;
+    }
+
     /// <inheritdoc/>
     public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames)
     {

+ 1 - 1
tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs

@@ -106,7 +106,7 @@ namespace Jellyfin.Server.Integration.Tests
             appHost.ServiceProvider = host.Services;
             var applicationPaths = appHost.ServiceProvider.GetRequiredService<IApplicationPaths>();
             Program.ApplyStartupMigrationAsync((ServerApplicationPaths)applicationPaths, appHost.ServiceProvider.GetRequiredService<IConfiguration>()).GetAwaiter().GetResult();
-            Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.CoreInitialisaition).GetAwaiter().GetResult();
+            Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.CoreInitialisation).GetAwaiter().GetResult();
             appHost.InitializeServices(Mock.Of<IConfiguration>()).GetAwaiter().GetResult();
             Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.AppInitialisation).GetAwaiter().GetResult();
             host.Start();