diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
index 18ebd628d1..e74755ec32 100644
--- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
+++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
@@ -78,6 +78,9 @@ namespace Emby.Server.Implementations.AppBase
///
public string TrickplayPath => Path.Combine(DataPath, "trickplay");
+ ///
+ public string BackupPath => Path.Combine(DataPath, "backups");
+
///
public virtual void MakeSanityCheckOrThrow()
{
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index c397a69fbd..565d0f0c85 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/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(this);
serviceCollection.AddSingleton(_pluginManager);
serviceCollection.AddSingleton(ApplicationPaths);
+ serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
diff --git a/Jellyfin.Api/Controllers/BackupController.cs b/Jellyfin.Api/Controllers/BackupController.cs
new file mode 100644
index 0000000000..aa908ee308
--- /dev/null
+++ b/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;
+
+///
+/// The backup controller.
+///
+[Authorize(Policy = Policies.RequiresElevation)]
+public class BackupController : BaseJellyfinApiController
+{
+ private readonly IBackupService _backupService;
+ private readonly IApplicationPaths _applicationPaths;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ /// Instance of the interface.
+ public BackupController(IBackupService backupService, IApplicationPaths applicationPaths)
+ {
+ _backupService = backupService;
+ _applicationPaths = applicationPaths;
+ }
+
+ ///
+ /// Creates a new Backup.
+ ///
+ /// The backup options.
+ /// Backup created.
+ /// User does not have permission to retrieve information.
+ /// The created backup manifest.
+ [HttpPost("Create")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task> CreateBackup([FromBody] BackupOptionsDto backupOptions)
+ {
+ return Ok(await _backupService.CreateBackupAsync(backupOptions ?? new()).ConfigureAwait(false));
+ }
+
+ ///
+ /// Restores to a backup by restarting the server and applying the backup.
+ ///
+ /// The data to start a restore process.
+ /// Backup restore started.
+ /// User does not have permission to retrieve information.
+ /// No-Content.
+ [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();
+ }
+
+ ///
+ /// Gets a list of all currently present backups in the backup directory.
+ ///
+ /// Backups available.
+ /// User does not have permission to retrieve information.
+ /// The list of backups.
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task> ListBackups()
+ {
+ return Ok(await _backupService.EnumerateBackups().ConfigureAwait(false));
+ }
+
+ ///
+ /// Gets the descriptor from an existing archive is present.
+ ///
+ /// The data to start a restore process.
+ /// Backup archive manifest.
+ /// Not a valid jellyfin Archive.
+ /// Not a valid path.
+ /// User does not have permission to retrieve information.
+ /// The backup manifest.
+ [HttpGet("Manifest")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task> 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;
+ }
+}
diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs
index 07a1f76503..450225c371 100644
--- a/Jellyfin.Api/Controllers/SystemController.cs
+++ b/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;
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs
new file mode 100644
index 0000000000..77a49b2b50
--- /dev/null
+++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace Jellyfin.Server.Implementations.FullSystemBackup;
+
+///
+/// Manifest type for backups internal structure.
+///
+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; }
+}
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs
new file mode 100644
index 0000000000..706f009ac2
--- /dev/null
+++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs
@@ -0,0 +1,13 @@
+namespace Jellyfin.Server.Implementations.FullSystemBackup;
+
+///
+/// Defines the optional contents of the backup archive.
+///
+internal class BackupOptions
+{
+ public bool Metadata { get; set; }
+
+ public bool Trickplay { get; set; }
+
+ public bool Subtitles { get; set; }
+}
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
new file mode 100644
index 0000000000..c3f5b01035
--- /dev/null
+++ b/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;
+
+///
+/// Contains methods for creating and restoring backups.
+///
+public class BackupService : IBackupService
+{
+ private const string ManifestEntryName = "manifest.json";
+ private readonly ILogger _logger;
+ private readonly IDbContextFactory _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");
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// A logger.
+ /// A Database Factory.
+ /// The Application host.
+ /// The application paths.
+ /// The Jellyfin database Provider in use.
+ public BackupService(
+ ILogger logger,
+ IDbContextFactory dbProvider,
+ IServerApplicationHost applicationHost,
+ IServerApplicationPaths applicationPaths,
+ IJellyfinDatabaseProvider jellyfinDatabaseProvider)
+ {
+ _logger = logger;
+ _dbProvider = dbProvider;
+ _applicationHost = applicationHost;
+ _applicationPaths = applicationPaths;
+ _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
+ }
+
+ ///
+ public void ScheduleRestoreAndRestartServer(string archivePath)
+ {
+ _applicationHost.RestoreBackupPath = archivePath;
+ _applicationHost.ShouldRestart = true;
+ _applicationHost.NotifyPendingRestart();
+ }
+
+ ///
+ 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(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(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;
+ }
+
+ ///
+ public async Task 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