diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs
index 706f009ac2..8bd108c443 100644
--- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs
+++ b/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; }
}
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
index 0e647fd241..e266d5a3bc 100644
--- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
+++ b/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
/// The Application host.
/// The application paths.
/// The Jellyfin database Provider in use.
- /// The SystemManager.
+ /// The SystemManager.
public BackupService(
ILogger logger,
IDbContextFactory 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;
}
///
@@ -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();
+ });
}
///
@@ -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("Begin restoring Database");
+ var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
{
- _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))
- {
- historyEntries = await JsonSerializer.DeserializeAsync(historyArchive).ConfigureAwait(false) ??
- throw new InvalidOperationException("Cannot restore backup that has no History data.");
- }
-
- var historyRepository = dbContext.GetService();
- await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
- foreach (var item in historyEntries)
- {
- var insertScript = historyRepository.GetInsertScript(item);
- await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
- }
-
- 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 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");
-
- foreach (var entityType in entityTypes)
- {
- _logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
-
- var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json");
- if (zipEntry is null)
+ // restore migration history manually
+ var historyEntry = zipArchive.GetEntry($"Database\\{nameof(HistoryRow)}.json");
+ if (historyEntry is null)
{
- _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
- continue;
+ _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 zipEntryStream = zipEntry.Open();
- await using (zipEntryStream.ConfigureAwait(false))
+ HistoryRow[] historyEntries;
+ var historyArchive = historyEntry.Open();
+ await using (historyArchive.ConfigureAwait(false))
{
- _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
- var records = 0;
- await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable(zipEntryStream, _serializerSettings).ConfigureAwait(false)!)
- {
- var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
- if (entity is null)
- {
- throw new InvalidOperationException($"Cannot deserialize entity '{item}'");
- }
+ historyEntries = await JsonSerializer.DeserializeAsync(historyArchive).ConfigureAwait(false) ??
+ throw new InvalidOperationException("Cannot restore backup that has no History data.");
+ }
- try
- {
- records++;
- dbContext.Add(entity);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item);
- }
+ var historyRepository = dbContext.GetService();
+ await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
+ foreach (var item in historyEntries)
+ {
+ var insertScript = historyRepository.GetInsertScript(item);
+ await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
+ }
+
+ 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 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");
+
+ foreach (var entityType in entityTypes)
+ {
+ _logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
+
+ var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json");
+ if (zipEntry is null)
+ {
+ _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
+ continue;
}
- _logger.LogInformation("Prepared to restore {Number} entries for {Table}", records, 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(zipEntryStream, _serializerSettings).ConfigureAwait(false)!)
+ {
+ var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
+ if (entity is null)
+ {
+ throw new InvalidOperationException($"Cannot deserialize entity '{item}'");
+ }
- _logger.LogInformation("Try restore Database");
- await dbContext.SaveChangesAsync().ConfigureAwait(false);
- _logger.LogInformation("Restored database.");
+ 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("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
};
}
}
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs b/Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs
index 5c8322ef78..70e54125b8 100644
--- a/Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs
@@ -47,9 +47,9 @@ public sealed class JellyfinMigrationAttribute : Attribute
public bool RunMigrationOnSetup { get; set; }
///
- /// Gets or Sets the stage the annoated migration should be executed at. Defaults to .
+ /// Gets or Sets the stage the annoated migration should be executed at. Defaults to .
///
- public JellyfinMigrationStageTypes Stage { get; set; } = JellyfinMigrationStageTypes.CoreInitialisaition;
+ public JellyfinMigrationStageTypes Stage { get; set; } = JellyfinMigrationStageTypes.CoreInitialisation;
///
/// Gets the ordering of the migration.
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationBackupAttribute.cs b/Jellyfin.Server/Migrations/JellyfinMigrationBackupAttribute.cs
new file mode 100644
index 0000000000..6c8da7e820
--- /dev/null
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationBackupAttribute.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace Jellyfin.Server.Migrations;
+
+///
+/// Marks an migration and instructs the to perform a backup.
+///
+[AttributeUsage(System.AttributeTargets.Class, Inherited = true, AllowMultiple = true)]
+public sealed class JellyfinMigrationBackupAttribute : System.Attribute
+{
+ ///
+ /// Gets or Sets a value indicating whether a backup of the old library.db should be performed.
+ ///
+ public bool LegacyLibraryDb { get; set; }
+
+ ///
+ /// Gets or Sets a value indicating whether a backup of the Database should be performed.
+ ///
+ public bool JellyfinDb { get; set; }
+
+ ///
+ /// Gets or Sets a value indicating whether a backup of the metadata folder should be performed.
+ ///
+ public bool Metadata { get; set; }
+
+ ///
+ /// Gets or Sets a value indicating whether a backup of the Trickplay folder should be performed.
+ ///
+ public bool Trickplay { get; set; }
+
+ ///
+ /// Gets or Sets a value indicating whether a backup of the Subtitles folder should be performed.
+ ///
+ public bool Subtitles { get; set; }
+}
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
index 3d6ed73bc6..fc4045da02 100644
--- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
+++ b/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;
///
internal class JellyfinMigrationService
{
+ private const string DbFilename = "library.db";
private readonly IDbContextFactory _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;
///
/// Initializes a new instance of the class.
///
/// Provides access to the jellyfin database.
/// The logger factory.
- public JellyfinMigrationService(IDbContextFactory dbContextFactory, ILoggerFactory loggerFactory)
+ /// Application paths for library.db backup.
+ /// The jellyfin backup service.
+ /// The jellyfin database provider.
+ public JellyfinMigrationService(
+ IDbContextFactory 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()))
+ .Select(e => (Type: e, Metadata: e.GetCustomAttribute(), Backup: e.GetCustomAttributes()))
.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 appliedMigrations;
+ var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var historyRepository = dbContext.GetService();
+ var migrationsAssembly = dbContext.GetService();
+ 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;
diff --git a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
index af8787b955..05ded06ba8 100644
--- a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
+++ b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
@@ -18,10 +18,10 @@ namespace Jellyfin.Server.Migrations.Routines
///
#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 _logger;
private readonly IServerApplicationPaths _applicationPaths;
private readonly IItemRepository _itemRepository;
@@ -39,29 +39,6 @@ namespace Jellyfin.Server.Migrations.Routines
///
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
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
index c9d2899407..309858ca70 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
+++ b/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.
///
[JellyfinMigration("2025-04-20T20:00:00", nameof(MigrateLibraryDb))]
+[JellyfinMigrationBackup(JellyfinDb = true, LegacyLibraryDb = true)]
internal class MigrateLibraryDb : IDatabaseMigrationRoutine
{
private const string DbFilename = "library.db";
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
index 234965c0a5..9aed449882 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
@@ -12,6 +12,7 @@ namespace Jellyfin.Server.Migrations.Routines;
///
#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
{
diff --git a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
index 1e4dfb237c..addbb69bfd 100644
--- a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
+++ b/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!;
diff --git a/Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs b/Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs
index d90ad3d9be..3d5ec233b3 100644
--- a/Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs
+++ b/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.
///
- CoreInitialisaition = 2,
+ CoreInitialisation = 2,
///
/// Runs after services has been registered and initialised. Last step before running the server.
diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs
index 4584b25bdf..9f2c71ce25 100644
--- a/Jellyfin.Server/Program.cs
+++ b/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(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);
diff --git a/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs
index 228839a1d8..fc5a109f1a 100644
--- a/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs
+++ b/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.
///
public bool Subtitles { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the archive contains the Database contents.
+ ///
+ public bool Database { get; set; } = true;
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
index b0dc984699..6b35810b22 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
@@ -64,6 +64,13 @@ public interface IJellyfinDatabaseProvider
/// A representing the result of the asynchronous operation.
Task RestoreBackupFast(string key, CancellationToken cancellationToken);
+ ///
+ /// Deletes a backup that has been previously created by .
+ ///
+ /// The key to the backup which should be cleaned up.
+ /// A representing the result of the asynchronous operation.
+ Task DeleteBackup(string key);
+
///
/// Removes all contents from the database.
///
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
index 519584003c..dda1ca0758 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
@@ -129,6 +129,21 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
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)
{
diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
index c09bce52da..b2cde2aabc 100644
--- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
+++ b/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();
Program.ApplyStartupMigrationAsync((ServerApplicationPaths)applicationPaths, appHost.ServiceProvider.GetRequiredService()).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()).GetAwaiter().GetResult();
Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.AppInitialisation).GetAwaiter().GetResult();
host.Start();