123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463 |
- 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
- };
- }
- }
|