diff options
| author | JPVenson <github@jpb.email> | 2025-05-19 03:39:04 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-18 18:39:04 -0600 |
| commit | fe2596dc0e389c0496a384cc1893fddd4742ed37 (patch) | |
| tree | 8d215ed776cbf399ea5545a737858690e79f4cb1 | |
| parent | cdbf4752b9834506a6782db357f95924902081a8 (diff) | |
Add Full system backup feature (#13945)
21 files changed, 841 insertions, 21 deletions
diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index 18ebd628d..e74755ec3 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -79,6 +79,9 @@ namespace Emby.Server.Implementations.AppBase public string TrickplayPath => Path.Combine(DataPath, "trickplay"); /// <inheritdoc /> + public string BackupPath => Path.Combine(DataPath, "backups"); + + /// <inheritdoc /> public virtual void MakeSanityCheckOrThrow() { CreateAndCheckMarker(ConfigurationDirectoryPath, "config"); diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index c397a69fb..565d0f0c8 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<IApplicationHost>(this); serviceCollection.AddSingleton<IPluginManager>(_pluginManager); serviceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths); + serviceCollection.AddSingleton<IBackupService, BackupService>(); serviceCollection.AddSingleton<IFileSystem, ManagedFileSystem>(); serviceCollection.AddSingleton<IShortcutHandler, MbLinkShortcutHandler>(); diff --git a/Jellyfin.Api/Controllers/BackupController.cs b/Jellyfin.Api/Controllers/BackupController.cs new file mode 100644 index 000000000..aa908ee30 --- /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; + +/// <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; + } +} diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index 07a1f7650..450225c37 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 000000000..77a49b2b5 --- /dev/null +++ b/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; } +} diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs new file mode 100644 index 000000000..706f009ac --- /dev/null +++ b/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; } +} diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs new file mode 100644 index 000000000..c3f5b0103 --- /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; + +/// <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 + }; + } +} diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index ebffab7ef..3d6ed73bc 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/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 diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 745f92420..4584b25bd 100644 --- a/Jellyfin.Server/Program.cs +++ b/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) { diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs index 91ac827ca..4890ccbb2 100644 --- a/Jellyfin.Server/StartupOptions.cs +++ b/Jellyfin.Server/StartupOptions.cs @@ -74,6 +74,12 @@ namespace Jellyfin.Server 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> /// <returns>The configuration dictionary.</returns> diff --git a/MediaBrowser.Common/Configuration/IApplicationPaths.cs b/MediaBrowser.Common/Configuration/IApplicationPaths.cs index fa0d8247b..6d1a72b04 100644 --- a/MediaBrowser.Common/Configuration/IApplicationPaths.cs +++ b/MediaBrowser.Common/Configuration/IApplicationPaths.cs @@ -92,6 +92,12 @@ namespace MediaBrowser.Common.Configuration 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> void MakeSanityCheckOrThrow(); diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs index e9c4d9e19..b76141db0 100644 --- a/MediaBrowser.Controller/IServerApplicationHost.cs +++ b/MediaBrowser.Controller/IServerApplicationHost.cs @@ -39,6 +39,11 @@ namespace MediaBrowser.Controller 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> /// <param name="request">The <see cref="HttpRequest"/> instance.</param> diff --git a/MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs new file mode 100644 index 000000000..b094ec275 --- /dev/null +++ b/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; } +} diff --git a/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs new file mode 100644 index 000000000..228839a1d --- /dev/null +++ b/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; } +} diff --git a/MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs new file mode 100644 index 000000000..263fa00c8 --- /dev/null +++ b/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; } +} diff --git a/MediaBrowser.Controller/SystemBackupService/IBackupService.cs b/MediaBrowser.Controller/SystemBackupService/IBackupService.cs new file mode 100644 index 000000000..0c586d811 --- /dev/null +++ b/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); +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs index 06b290e4f..39b449553 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs +++ b/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> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs index 4da7074ec..6c81fa729 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs +++ b/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> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs index 34ac7dc83..b0dc98469 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs +++ b/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); } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index 156d9618e..519584003 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/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); + } } diff --git a/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs index dd84c1a18..8cb3cde2b 100644 --- a/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs +++ b/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; |
