2
0
Эх сурвалжийг харах

Add Full system backup feature (#13945)

JPVenson 1 долоо хоног өмнө
parent
commit
fe2596dc0e
21 өөрчлөгдсөн 841 нэмэгдсэн , 21 устгасан
  1. 3 0
      Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
  2. 5 0
      Emby.Server.Implementations/ApplicationHost.cs
  3. 127 0
      Jellyfin.Api/Controllers/BackupController.cs
  4. 0 1
      Jellyfin.Api/Controllers/SystemController.cs
  5. 19 0
      Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs
  6. 13 0
      Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs
  7. 463 0
      Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
  8. 27 16
      Jellyfin.Server/Migrations/JellyfinMigrationService.cs
  9. 13 1
      Jellyfin.Server/Program.cs
  10. 6 0
      Jellyfin.Server/StartupOptions.cs
  11. 6 0
      MediaBrowser.Common/Configuration/IApplicationPaths.cs
  12. 5 0
      MediaBrowser.Controller/IServerApplicationHost.cs
  13. 34 0
      MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs
  14. 24 0
      MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs
  15. 15 0
      MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs
  16. 48 0
      MediaBrowser.Controller/SystemBackupService/IBackupService.cs
  17. 0 1
      src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs
  18. 0 1
      src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
  19. 9 0
      src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
  20. 23 1
      src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
  21. 1 0
      tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs

+ 3 - 0
Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs

@@ -78,6 +78,9 @@ namespace Emby.Server.Implementations.AppBase
         /// <inheritdoc />
         public string TrickplayPath => Path.Combine(DataPath, "trickplay");
 
+        /// <inheritdoc />
+        public string BackupPath => Path.Combine(DataPath, "backups");
+
         /// <inheritdoc />
         public virtual void MakeSanityCheckOrThrow()
         {

+ 5 - 0
Emby.Server.Implementations/ApplicationHost.cs

@@ -40,8 +40,10 @@ using Jellyfin.Drawing;
 using Jellyfin.MediaEncoding.Hls.Playlist;
 using Jellyfin.Networking.Manager;
 using Jellyfin.Networking.Udp;
+using Jellyfin.Server.Implementations.FullSystemBackup;
 using Jellyfin.Server.Implementations.Item;
 using Jellyfin.Server.Implementations.MediaSegments;
+using Jellyfin.Server.Implementations.SystemBackupService;
 using MediaBrowser.Common;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Events;
@@ -268,6 +270,8 @@ namespace Emby.Server.Implementations
                 ? Environment.MachineName
                 : ConfigurationManager.Configuration.ServerName;
 
+        public string RestoreBackupPath { get; set; }
+
         public string ExpandVirtualPath(string path)
         {
             if (path is null)
@@ -472,6 +476,7 @@ namespace Emby.Server.Implementations
             serviceCollection.AddSingleton<IApplicationHost>(this);
             serviceCollection.AddSingleton<IPluginManager>(_pluginManager);
             serviceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths);
+            serviceCollection.AddSingleton<IBackupService, BackupService>();
 
             serviceCollection.AddSingleton<IFileSystem, ManagedFileSystem>();
             serviceCollection.AddSingleton<IShortcutHandler, MbLinkShortcutHandler>();

+ 127 - 0
Jellyfin.Api/Controllers/BackupController.cs

@@ -0,0 +1,127 @@
+using System.IO;
+using System.Threading.Tasks;
+using Jellyfin.Server.Implementations.SystemBackupService;
+using MediaBrowser.Common.Api;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.SystemBackupService;
+using Microsoft.AspNetCore.Authentication.OAuth.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The backup controller.
+/// </summary>
+[Authorize(Policy = Policies.RequiresElevation)]
+public class BackupController : BaseJellyfinApiController
+{
+    private readonly IBackupService _backupService;
+    private readonly IApplicationPaths _applicationPaths;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BackupController"/> class.
+    /// </summary>
+    /// <param name="backupService">Instance of the <see cref="IBackupService"/> interface.</param>
+    /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+    public BackupController(IBackupService backupService, IApplicationPaths applicationPaths)
+    {
+        _backupService = backupService;
+        _applicationPaths = applicationPaths;
+    }
+
+    /// <summary>
+    /// Creates a new Backup.
+    /// </summary>
+    /// <param name="backupOptions">The backup options.</param>
+    /// <response code="200">Backup created.</response>
+    /// <response code="403">User does not have permission to retrieve information.</response>
+    /// <returns>The created backup manifest.</returns>
+    [HttpPost("Create")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public async Task<ActionResult<BackupManifestDto>> CreateBackup([FromBody] BackupOptionsDto backupOptions)
+    {
+        return Ok(await _backupService.CreateBackupAsync(backupOptions ?? new()).ConfigureAwait(false));
+    }
+
+    /// <summary>
+    /// Restores to a backup by restarting the server and applying the backup.
+    /// </summary>
+    /// <param name="archiveRestoreDto">The data to start a restore process.</param>
+    /// <response code="204">Backup restore started.</response>
+    /// <response code="403">User does not have permission to retrieve information.</response>
+    /// <returns>No-Content.</returns>
+    [HttpPost("Restore")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public IActionResult StartRestoreBackup([FromBody, BindRequired] BackupRestoreRequestDto archiveRestoreDto)
+    {
+        var archivePath = SanitizePath(archiveRestoreDto.ArchiveFileName);
+        if (!System.IO.File.Exists(archivePath))
+        {
+            return NotFound();
+        }
+
+        _backupService.ScheduleRestoreAndRestartServer(archivePath);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Gets a list of all currently present backups in the backup directory.
+    /// </summary>
+    /// <response code="200">Backups available.</response>
+    /// <response code="403">User does not have permission to retrieve information.</response>
+    /// <returns>The list of backups.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public async Task<ActionResult<BackupManifestDto[]>> ListBackups()
+    {
+        return Ok(await _backupService.EnumerateBackups().ConfigureAwait(false));
+    }
+
+    /// <summary>
+    /// Gets the descriptor from an existing archive is present.
+    /// </summary>
+    /// <param name="path">The data to start a restore process.</param>
+    /// <response code="200">Backup archive manifest.</response>
+    /// <response code="204">Not a valid jellyfin Archive.</response>
+    /// <response code="404">Not a valid path.</response>
+    /// <response code="403">User does not have permission to retrieve information.</response>
+    /// <returns>The backup manifest.</returns>
+    [HttpGet("Manifest")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public async Task<ActionResult<BackupManifestDto>> GetBackup([BindRequired] string path)
+    {
+        var backupPath = SanitizePath(path);
+
+        if (!System.IO.File.Exists(backupPath))
+        {
+            return NotFound();
+        }
+
+        var manifest = await _backupService.GetBackupManifest(backupPath).ConfigureAwait(false);
+        if (manifest is null)
+        {
+            return NoContent();
+        }
+
+        return Ok(manifest);
+    }
+
+    [NonAction]
+    private string SanitizePath(string path)
+    {
+        // sanitize path
+        var archiveRestorePath = Path.GetFileName(Path.GetFullPath(path));
+        var archivePath = Path.Combine(_applicationPaths.BackupPath, archiveRestorePath);
+        return archivePath;
+    }
+}

+ 0 - 1
Jellyfin.Api/Controllers/SystemController.cs

@@ -5,7 +5,6 @@ using System.IO;
 using System.Linq;
 using System.Net.Mime;
 using Jellyfin.Api.Attributes;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Models.SystemInfoDtos;
 using MediaBrowser.Common.Api;
 using MediaBrowser.Common.Configuration;

+ 19 - 0
Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs

@@ -0,0 +1,19 @@
+using System;
+
+namespace Jellyfin.Server.Implementations.FullSystemBackup;
+
+/// <summary>
+/// Manifest type for backups internal structure.
+/// </summary>
+internal class BackupManifest
+{
+    public required Version ServerVersion { get; set; }
+
+    public required Version BackupEngineVersion { get; set; }
+
+    public required DateTimeOffset DateCreated { get; set; }
+
+    public required string[] DatabaseTables { get; set; }
+
+    public required BackupOptions Options { get; set; }
+}

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

@@ -0,0 +1,13 @@
+namespace Jellyfin.Server.Implementations.FullSystemBackup;
+
+/// <summary>
+/// Defines the optional contents of the backup archive.
+/// </summary>
+internal class BackupOptions
+{
+    public bool Metadata { get; set; }
+
+    public bool Trickplay { get; set; }
+
+    public bool Subtitles { get; set; }
+}

+ 463 - 0
Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs

@@ -0,0 +1,463 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.Implementations.StorageHelpers;
+using Jellyfin.Server.Implementations.SystemBackupService;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.SystemBackupService;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Implementations.FullSystemBackup;
+
+/// <summary>
+/// Contains methods for creating and restoring backups.
+/// </summary>
+public class BackupService : IBackupService
+{
+    private const string ManifestEntryName = "manifest.json";
+    private readonly ILogger<BackupService> _logger;
+    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+    private readonly IServerApplicationHost _applicationHost;
+    private readonly IServerApplicationPaths _applicationPaths;
+    private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
+    private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
+    {
+        AllowTrailingCommas = true,
+        ReferenceHandler = ReferenceHandler.IgnoreCycles,
+    };
+
+    private readonly Version _backupEngineVersion = Version.Parse("0.1.0");
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BackupService"/> class.
+    /// </summary>
+    /// <param name="logger">A logger.</param>
+    /// <param name="dbProvider">A Database Factory.</param>
+    /// <param name="applicationHost">The Application host.</param>
+    /// <param name="applicationPaths">The application paths.</param>
+    /// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
+    public BackupService(
+        ILogger<BackupService> logger,
+        IDbContextFactory<JellyfinDbContext> dbProvider,
+        IServerApplicationHost applicationHost,
+        IServerApplicationPaths applicationPaths,
+        IJellyfinDatabaseProvider jellyfinDatabaseProvider)
+    {
+        _logger = logger;
+        _dbProvider = dbProvider;
+        _applicationHost = applicationHost;
+        _applicationPaths = applicationPaths;
+        _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
+    }
+
+    /// <inheritdoc/>
+    public void ScheduleRestoreAndRestartServer(string archivePath)
+    {
+        _applicationHost.RestoreBackupPath = archivePath;
+        _applicationHost.ShouldRestart = true;
+        _applicationHost.NotifyPendingRestart();
+    }
+
+    /// <inheritdoc/>
+    public async Task RestoreBackupAsync(string archivePath)
+    {
+        _logger.LogWarning("Begin restoring system to {BackupArchive}", archivePath); // Info isn't cutting it
+        if (!File.Exists(archivePath))
+        {
+            throw new FileNotFoundException($"Requested backup file '{archivePath}' does not exist.");
+        }
+
+        StorageHelper.TestCommonPathsForStorageCapacity(_applicationPaths, _logger);
+
+        var fileStream = File.OpenRead(archivePath);
+        await using (fileStream.ConfigureAwait(false))
+        {
+            using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read, false);
+            var zipArchiveEntry = zipArchive.GetEntry(ManifestEntryName);
+
+            if (zipArchiveEntry is null)
+            {
+                throw new NotSupportedException($"The loaded archive '{archivePath}' does not appear to be a Jellyfin backup as its missing the '{ManifestEntryName}'.");
+            }
+
+            BackupManifest? manifest;
+            var manifestStream = zipArchiveEntry.Open();
+            await using (manifestStream.ConfigureAwait(false))
+            {
+                manifest = await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
+            }
+
+            if (manifest!.ServerVersion > _applicationHost.ApplicationVersion) // newer versions of Jellyfin should be able to load older versions as we have migrations.
+            {
+                throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
+            }
+
+            if (!TestBackupVersionCompatibility(manifest.BackupEngineVersion))
+            {
+                throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
+            }
+
+            void CopyDirectory(string source, string target)
+            {
+                source = Path.GetFullPath(source);
+                Directory.CreateDirectory(source);
+
+                foreach (var item in zipArchive.Entries)
+                {
+                    var sanitizedSourcePath = Path.GetFullPath(item.FullName.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar);
+                    if (!sanitizedSourcePath.StartsWith(target, StringComparison.Ordinal))
+                    {
+                        continue;
+                    }
+
+                    var targetPath = Path.Combine(source, sanitizedSourcePath[target.Length..].Trim('/'));
+                    _logger.LogInformation("Restore and override {File}", targetPath);
+                    item.ExtractToFile(targetPath);
+                }
+            }
+
+            CopyDirectory(_applicationPaths.ConfigurationDirectoryPath, "Config/");
+            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))
+            {
+                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;
+                    }
+
+                    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 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("Try restore Database");
+                await dbContext.SaveChangesAsync().ConfigureAwait(false);
+                _logger.LogInformation("Restored database.");
+            }
+
+            _logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated);
+        }
+    }
+
+    private bool TestBackupVersionCompatibility(Version backupEngineVersion)
+    {
+        if (backupEngineVersion == _backupEngineVersion)
+        {
+            return true;
+        }
+
+        return false;
+    }
+
+    /// <inheritdoc/>
+    public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
+    {
+        var manifest = new BackupManifest()
+        {
+            DateCreated = DateTime.UtcNow,
+            ServerVersion = _applicationHost.ApplicationVersion,
+            DatabaseTables = null!,
+            BackupEngineVersion = _backupEngineVersion,
+            Options = Map(backupOptions)
+        };
+
+        await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
+
+        var backupFolder = Path.Combine(_applicationPaths.BackupPath);
+
+        if (!Directory.Exists(backupFolder))
+        {
+            Directory.CreateDirectory(backupFolder);
+        }
+
+        var backupStorageSpace = StorageHelper.GetFreeSpaceOf(_applicationPaths.BackupPath);
+
+        const long FiveGigabyte = 5_368_709_115;
+        if (backupStorageSpace.FreeSpace < FiveGigabyte)
+        {
+            throw new InvalidOperationException($"The backup directory '{backupStorageSpace.Path}' does not have at least '{StorageHelper.HumanizeStorageSize(FiveGigabyte)}' free space. Cannot create backup.");
+        }
+
+        var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
+        _logger.LogInformation("Attempt to create a new backup at {BackupPath}", backupPath);
+        var fileStream = File.OpenWrite(backupPath);
+        await using (fileStream.ConfigureAwait(false))
+        using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
+        {
+            _logger.LogInformation("Start backup process.");
+            var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+            await using (dbContext.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();
+                manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
+                var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
+
+                await using (transaction.ConfigureAwait(false))
+                {
+                    _logger.LogInformation("Begin Database backup");
+                    static IAsyncEnumerable<object> GetValues(IQueryable dbSet, Type type)
+                    {
+                        var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
+                        var enumerable = method.Invoke(dbSet, null)!;
+                        return (IAsyncEnumerable<object>)enumerable;
+                    }
+
+                    foreach (var entityType in entityTypes)
+                    {
+                        _logger.LogInformation("Begin backup of entity {Table}", entityType.Type.Name);
+                        var zipEntry = zipArchive.CreateEntry($"Database\\{entityType.Type.Name}.json");
+                        var entities = 0;
+                        var zipEntryStream = zipEntry.Open();
+                        await using (zipEntryStream.ConfigureAwait(false))
+                        {
+                            var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
+                            await using (jsonSerializer.ConfigureAwait(false))
+                            {
+                                jsonSerializer.WriteStartArray();
+
+                                var set = GetValues(entityType.Set!, entityType.Type.PropertyType).ConfigureAwait(false);
+                                await foreach (var item in set.ConfigureAwait(false))
+                                {
+                                    entities++;
+                                    try
+                                    {
+                                        JsonSerializer.SerializeToDocument(item, _serializerSettings).WriteTo(jsonSerializer);
+                                    }
+                                    catch (Exception ex)
+                                    {
+                                        _logger.LogError(ex, "Could not load entity {Entity}", item);
+                                        throw;
+                                    }
+                                }
+
+                                jsonSerializer.WriteEndArray();
+                            }
+                        }
+
+                        _logger.LogInformation("backup of entity {Table} with {Number} created", entityType.Type.Name, entities);
+                    }
+                }
+            }
+
+            _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
+            foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
+              .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
+            {
+                zipArchive.CreateEntryFromFile(item, Path.Combine("Config", Path.GetFileName(item)));
+            }
+
+            void CopyDirectory(string source, string target, string filter = "*")
+            {
+                if (!Directory.Exists(source))
+                {
+                    return;
+                }
+
+                _logger.LogInformation("Backup of folder {Table}", source);
+
+                foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
+                {
+                    zipArchive.CreateEntryFromFile(item, Path.Combine(target, item[..source.Length].Trim('\\')));
+                }
+            }
+
+            CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
+            CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
+            CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
+            CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
+            CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
+            CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
+            if (backupOptions.Subtitles)
+            {
+                CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
+            }
+
+            if (backupOptions.Trickplay)
+            {
+                CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
+            }
+
+            if (backupOptions.Metadata)
+            {
+                CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
+            }
+
+            var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
+            await using (manifestStream.ConfigureAwait(false))
+            {
+                await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
+            }
+        }
+
+        _logger.LogInformation("Backup created");
+        return Map(manifest, backupPath);
+    }
+
+    /// <inheritdoc/>
+    public async Task<BackupManifestDto?> GetBackupManifest(string archivePath)
+    {
+        if (!File.Exists(archivePath))
+        {
+            return null;
+        }
+
+        BackupManifest? manifest;
+        try
+        {
+            manifest = await GetManifest(archivePath).ConfigureAwait(false);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Tried to load archive from {Path} but failed.", archivePath);
+            return null;
+        }
+
+        if (manifest is null)
+        {
+            return null;
+        }
+
+        return Map(manifest, archivePath);
+    }
+
+    /// <inheritdoc/>
+    public async Task<BackupManifestDto[]> EnumerateBackups()
+    {
+        if (!Directory.Exists(_applicationPaths.BackupPath))
+        {
+            return [];
+        }
+
+        var archives = Directory.EnumerateFiles(_applicationPaths.BackupPath, "*.zip");
+        var manifests = new List<BackupManifestDto>();
+        foreach (var item in archives)
+        {
+            try
+            {
+                var manifest = await GetManifest(item).ConfigureAwait(false);
+
+                if (manifest is null)
+                {
+                    continue;
+                }
+
+                manifests.Add(Map(manifest, item));
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Could not load {BackupArchive} path.", item);
+            }
+        }
+
+        return manifests.ToArray();
+    }
+
+    private static async ValueTask<BackupManifest?> GetManifest(string archivePath)
+    {
+        var archiveStream = File.OpenRead(archivePath);
+        await using (archiveStream.ConfigureAwait(false))
+        {
+            using var zipStream = new ZipArchive(archiveStream, ZipArchiveMode.Read);
+            var manifestEntry = zipStream.GetEntry(ManifestEntryName);
+            if (manifestEntry is null)
+            {
+                return null;
+            }
+
+            var manifestStream = manifestEntry.Open();
+            await using (manifestStream.ConfigureAwait(false))
+            {
+                return await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
+            }
+        }
+    }
+
+    private static BackupManifestDto Map(BackupManifest manifest, string path)
+    {
+        return new BackupManifestDto()
+        {
+            BackupEngineVersion = manifest.BackupEngineVersion,
+            DateCreated = manifest.DateCreated,
+            ServerVersion = manifest.ServerVersion,
+            Path = path,
+            Options = Map(manifest.Options)
+        };
+    }
+
+    private static BackupOptionsDto Map(BackupOptions options)
+    {
+        return new BackupOptionsDto()
+        {
+            Metadata = options.Metadata,
+            Subtitles = options.Subtitles,
+            Trickplay = options.Trickplay
+        };
+    }
+
+    private static BackupOptions Map(BackupOptionsDto options)
+    {
+        return new BackupOptions()
+        {
+            Metadata = options.Metadata,
+            Subtitles = options.Subtitles,
+            Trickplay = options.Trickplay
+        };
+    }
+}

+ 27 - 16
Jellyfin.Server/Migrations/JellyfinMigrationService.cs

@@ -7,8 +7,10 @@ using System.Threading;
 using System.Threading.Tasks;
 using Emby.Server.Implementations.Serialization;
 using Jellyfin.Database.Implementations;
+using Jellyfin.Server.Implementations.SystemBackupService;
 using Jellyfin.Server.Migrations.Stages;
 using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.SystemBackupService;
 using MediaBrowser.Model.Configuration;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -103,25 +105,33 @@ internal class JellyfinMigrationService
             if (migrationOptions != null && migrationOptions.Applied.Count > 0)
             {
                 logger.LogInformation("Old migration style migration.xml detected. Migrate now.");
-                var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
-                await using (dbContext.ConfigureAwait(false))
+                try
                 {
-                    var historyRepository = dbContext.GetService<IHistoryRepository>();
-                    var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false);
-                    var oldMigrations = Migrations
-                        .SelectMany(e => e.Where(e => e.Metadata.Key is not null)) // only consider migrations that have the key set as its the reference marker for legacy migrations.
-                        .Where(e => migrationOptions.Applied.Any(f => f.Id.Equals(e.Metadata.Key!.Value)))
-                        .Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId()))
-                        .ToArray();
-                    var startupScripts = oldMigrations.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))));
-                    foreach (var item in startupScripts)
+                    var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+                    await using (dbContext.ConfigureAwait(false))
                     {
-                        logger.LogInformation("Migrate migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
-                        await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
+                        var historyRepository = dbContext.GetService<IHistoryRepository>();
+                        var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false);
+                        var oldMigrations = Migrations
+                            .SelectMany(e => e.Where(e => e.Metadata.Key is not null)) // only consider migrations that have the key set as its the reference marker for legacy migrations.
+                            .Where(e => migrationOptions.Applied.Any(f => f.Id.Equals(e.Metadata.Key!.Value)))
+                            .Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId()))
+                            .ToArray();
+                        var startupScripts = oldMigrations.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))));
+                        foreach (var item in startupScripts)
+                        {
+                            logger.LogInformation("Migrate migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
+                            await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
+                        }
+
+                        logger.LogInformation("Rename old migration.xml to migration.xml.backup");
+                        File.Move(migrationConfigPath, Path.ChangeExtension(migrationConfigPath, ".xml.backup"), true);
                     }
-
-                    logger.LogInformation("Rename old migration.xml to migration.xml.backup");
-                    File.Move(migrationConfigPath, Path.ChangeExtension(migrationConfigPath, ".xml.backup"), true);
+                }
+                catch (Exception ex)
+                {
+                    logger.LogCritical(ex, "Failed to apply migrations");
+                    throw;
                 }
             }
         }
@@ -155,6 +165,7 @@ internal class JellyfinMigrationService
             (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
             logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
             var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
+
             foreach (var item in migrations)
             {
                 try

+ 13 - 1
Jellyfin.Server/Program.cs

@@ -16,7 +16,9 @@ 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.ServerSetupApp;
 using MediaBrowser.Common.Configuration;
@@ -58,6 +60,7 @@ namespace Jellyfin.Server
         private static long _startTimestamp;
         private static ILogger _logger = NullLogger.Instance;
         private static bool _restartOnShutdown;
+        private static string? _restoreFromBackup;
 
         /// <summary>
         /// The entry point of the application.
@@ -79,6 +82,7 @@ namespace Jellyfin.Server
 
         private static async Task StartApp(StartupOptions options)
         {
+            _restoreFromBackup = options.RestoreArchive;
             _startTimestamp = Stopwatch.GetTimestamp();
             ServerApplicationPaths appPaths = StartupHelpers.CreateApplicationPaths(options);
             appPaths.MakeSanityCheckOrThrow();
@@ -176,9 +180,16 @@ namespace Jellyfin.Server
 
                 // Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection.
                 appHost.ServiceProvider = _jellyfinHost.Services;
-
                 PrepareDatabaseProvider(appHost.ServiceProvider);
 
+                if (!string.IsNullOrWhiteSpace(_restoreFromBackup))
+                {
+                    await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false);
+                    _restoreFromBackup = null;
+                    _restartOnShutdown = true;
+                    return;
+                }
+
                 await ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.CoreInitialisaition).ConfigureAwait(false);
 
                 await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
@@ -209,6 +220,7 @@ namespace Jellyfin.Server
 
                 await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false);
                 _restartOnShutdown = appHost.ShouldRestart;
+                _restoreFromBackup = appHost.RestoreBackupPath;
             }
             catch (Exception ex)
             {

+ 6 - 0
Jellyfin.Server/StartupOptions.cs

@@ -73,6 +73,12 @@ namespace Jellyfin.Server
         [Option("nonetchange", Required = false, HelpText = "Indicates that the server should not detect network status change.")]
         public bool NoDetectNetworkChange { get; set; }
 
+        /// <summary>
+        /// Gets or sets the path to an jellyfin backup archive to restore the application to.
+        /// </summary>
+        [Option("restore-archive", Required = false, HelpText = "Path to a Jellyfin backup archive to restore from")]
+        public string? RestoreArchive { get; set; }
+
         /// <summary>
         /// Gets the command line options as a dictionary that can be used in the .NET configuration system.
         /// </summary>

+ 6 - 0
MediaBrowser.Common/Configuration/IApplicationPaths.cs

@@ -91,6 +91,12 @@ namespace MediaBrowser.Common.Configuration
         /// <value>The trickplay path.</value>
         string TrickplayPath { get; }
 
+        /// <summary>
+        /// Gets the path used for storing backup archives.
+        /// </summary>
+        /// <value>The backup path.</value>
+        string BackupPath { get; }
+
         /// <summary>
         /// Checks and creates all known base paths.
         /// </summary>

+ 5 - 0
MediaBrowser.Controller/IServerApplicationHost.cs

@@ -38,6 +38,11 @@ namespace MediaBrowser.Controller
         /// <value>The name of the friendly.</value>
         string FriendlyName { get; }
 
+        /// <summary>
+        /// Gets or sets the path to the backup archive used to restore upon restart.
+        /// </summary>
+        string RestoreBackupPath { get; set; }
+
         /// <summary>
         /// Gets a URL specific for the request.
         /// </summary>

+ 34 - 0
MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs

@@ -0,0 +1,34 @@
+using System;
+
+namespace MediaBrowser.Controller.SystemBackupService;
+
+/// <summary>
+/// Manifest type for backups internal structure.
+/// </summary>
+public class BackupManifestDto
+{
+    /// <summary>
+    /// Gets or sets the jellyfin version this backup was created with.
+    /// </summary>
+    public required Version ServerVersion { get; set; }
+
+    /// <summary>
+    /// Gets or sets the backup engine version this backup was created with.
+    /// </summary>
+    public required Version BackupEngineVersion { get; set; }
+
+    /// <summary>
+    /// Gets or sets the date this backup was created with.
+    /// </summary>
+    public required DateTimeOffset DateCreated { get; set; }
+
+    /// <summary>
+    /// Gets or sets the path to the backup on the system.
+    /// </summary>
+    public required string Path { get; set; }
+
+    /// <summary>
+    /// Gets or sets the contents of the backup archive.
+    /// </summary>
+    public required BackupOptionsDto Options { get; set; }
+}

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

@@ -0,0 +1,24 @@
+using System;
+
+namespace MediaBrowser.Controller.SystemBackupService;
+
+/// <summary>
+/// Defines the optional contents of the backup archive.
+/// </summary>
+public class BackupOptionsDto
+{
+    /// <summary>
+    /// Gets or sets a value indicating whether the archive contains the Metadata contents.
+    /// </summary>
+    public bool Metadata { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether the archive contains the Trickplay contents.
+    /// </summary>
+    public bool Trickplay { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether the archive contains the Subtitle contents.
+    /// </summary>
+    public bool Subtitles { get; set; }
+}

+ 15 - 0
MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs

@@ -0,0 +1,15 @@
+using System;
+using MediaBrowser.Common.Configuration;
+
+namespace MediaBrowser.Controller.SystemBackupService;
+
+/// <summary>
+/// Defines properties used to start a restore process.
+/// </summary>
+public class BackupRestoreRequestDto
+{
+    /// <summary>
+    /// Gets or Sets the name of the backup archive to restore from. Must be present in <see cref="IApplicationPaths.BackupPath"/>.
+    /// </summary>
+    public required string ArchiveFileName { get; set; }
+}

+ 48 - 0
MediaBrowser.Controller/SystemBackupService/IBackupService.cs

@@ -0,0 +1,48 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.SystemBackupService;
+
+namespace Jellyfin.Server.Implementations.SystemBackupService;
+
+/// <summary>
+/// Defines an interface to restore and backup the jellyfin system.
+/// </summary>
+public interface IBackupService
+{
+    /// <summary>
+    /// Creates a new Backup zip file containing the current state of the application.
+    /// </summary>
+    /// <param name="backupOptions">The backup options.</param>
+    /// <returns>A task.</returns>
+    Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions);
+
+    /// <summary>
+    /// Gets a list of backups that are available to be restored from.
+    /// </summary>
+    /// <returns>A list of backup paths.</returns>
+    Task<BackupManifestDto[]> EnumerateBackups();
+
+    /// <summary>
+    /// Gets a single backup manifest if the path defines a valid Jellyfin backup archive.
+    /// </summary>
+    /// <param name="archivePath">The path to be loaded.</param>
+    /// <returns>The containing backup manifest or null if not existing or compatiable.</returns>
+    Task<BackupManifestDto?> GetBackupManifest(string archivePath);
+
+    /// <summary>
+    /// Restores an backup zip file created by jellyfin.
+    /// </summary>
+    /// <param name="archivePath">Path to the archive.</param>
+    /// <returns>A Task.</returns>
+    /// <exception cref="FileNotFoundException">Thrown when an invalid or missing file is specified.</exception>
+    /// <exception cref="NotSupportedException">Thrown when attempt to load an unsupported backup is made.</exception>
+    /// <exception cref="InvalidOperationException">Thrown for errors during the restore.</exception>
+    Task RestoreBackupAsync(string archivePath);
+
+    /// <summary>
+    /// Schedules a Restore and restarts the server.
+    /// </summary>
+    /// <param name="archivePath">The path to the archive to restore from.</param>
+    void ScheduleRestoreAndRestartServer(string archivePath);
+}

+ 0 - 1
src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs

@@ -14,7 +14,6 @@ public class TrickplayInfo
     /// <remarks>
     /// Required.
     /// </remarks>
-    [JsonIgnore]
     public Guid ItemId { get; set; }
 
     /// <summary>

+ 0 - 1
src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs

@@ -61,7 +61,6 @@ namespace Jellyfin.Database.Implementations.Entities
         /// <remarks>
         /// Identity, Indexed, Required.
         /// </remarks>
-        [JsonIgnore]
         public Guid Id { get; set; }
 
         /// <summary>

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

@@ -1,4 +1,5 @@
 using System;
+using System.Collections.Generic;
 using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.EntityFrameworkCore;
@@ -62,4 +63,12 @@ public interface IJellyfinDatabaseProvider
     /// <param name="cancellationToken">A cancellation token.</param>
     /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
     Task RestoreBackupFast(string key, CancellationToken cancellationToken);
+
+    /// <summary>
+    /// Removes all contents from the database.
+    /// </summary>
+    /// <param name="dbContext">The Database context.</param>
+    /// <param name="tableNames">The names of the tables to purge or null for all tables to be purged.</param>
+    /// <returns>A Task.</returns>
+    Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames);
 }

+ 23 - 1
src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs

@@ -1,4 +1,5 @@
 using System;
+using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
 using System.Threading;
@@ -82,7 +83,7 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
         }
 
         // Run before disposing the application
-        var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+        var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
         await using (context.ConfigureAwait(false))
         {
             await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false);
@@ -127,4 +128,25 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
         File.Copy(backupFile, path, true);
         return Task.CompletedTask;
     }
+
+    /// <inheritdoc/>
+    public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames)
+    {
+        ArgumentNullException.ThrowIfNull(tableNames);
+
+        var deleteQueries = new List<string>();
+        foreach (var tableName in tableNames)
+        {
+            deleteQueries.Add($"DELETE FROM \"{tableName}\";");
+        }
+
+        var deleteAllQuery =
+        $"""
+        PRAGMA foreign_keys = OFF;
+        {string.Join('\n', deleteQueries)}
+        PRAGMA foreign_keys = ON;
+        """;
+
+        await dbContext.Database.ExecuteSqlRawAsync(deleteAllQuery).ConfigureAwait(false);
+    }
 }

+ 1 - 0
tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs

@@ -1,4 +1,5 @@
 using Jellyfin.Api.Controllers;
+using Jellyfin.Server.Implementations.SystemBackupService;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller;
 using MediaBrowser.Model.IO;