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