|  | @@ -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;
 |