mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-05-24 02:02:29 -04:00
Add Full system backup feature (#13945)
This commit is contained in:
parent
cdbf4752b9
commit
fe2596dc0e
@ -78,6 +78,9 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string TrickplayPath => Path.Combine(DataPath, "trickplay");
|
public string TrickplayPath => Path.Combine(DataPath, "trickplay");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string BackupPath => Path.Combine(DataPath, "backups");
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public virtual void MakeSanityCheckOrThrow()
|
public virtual void MakeSanityCheckOrThrow()
|
||||||
{
|
{
|
||||||
|
@ -40,8 +40,10 @@ using Jellyfin.Drawing;
|
|||||||
using Jellyfin.MediaEncoding.Hls.Playlist;
|
using Jellyfin.MediaEncoding.Hls.Playlist;
|
||||||
using Jellyfin.Networking.Manager;
|
using Jellyfin.Networking.Manager;
|
||||||
using Jellyfin.Networking.Udp;
|
using Jellyfin.Networking.Udp;
|
||||||
|
using Jellyfin.Server.Implementations.FullSystemBackup;
|
||||||
using Jellyfin.Server.Implementations.Item;
|
using Jellyfin.Server.Implementations.Item;
|
||||||
using Jellyfin.Server.Implementations.MediaSegments;
|
using Jellyfin.Server.Implementations.MediaSegments;
|
||||||
|
using Jellyfin.Server.Implementations.SystemBackupService;
|
||||||
using MediaBrowser.Common;
|
using MediaBrowser.Common;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Events;
|
using MediaBrowser.Common.Events;
|
||||||
@ -268,6 +270,8 @@ namespace Emby.Server.Implementations
|
|||||||
? Environment.MachineName
|
? Environment.MachineName
|
||||||
: ConfigurationManager.Configuration.ServerName;
|
: ConfigurationManager.Configuration.ServerName;
|
||||||
|
|
||||||
|
public string RestoreBackupPath { get; set; }
|
||||||
|
|
||||||
public string ExpandVirtualPath(string path)
|
public string ExpandVirtualPath(string path)
|
||||||
{
|
{
|
||||||
if (path is null)
|
if (path is null)
|
||||||
@ -472,6 +476,7 @@ namespace Emby.Server.Implementations
|
|||||||
serviceCollection.AddSingleton<IApplicationHost>(this);
|
serviceCollection.AddSingleton<IApplicationHost>(this);
|
||||||
serviceCollection.AddSingleton<IPluginManager>(_pluginManager);
|
serviceCollection.AddSingleton<IPluginManager>(_pluginManager);
|
||||||
serviceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths);
|
serviceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths);
|
||||||
|
serviceCollection.AddSingleton<IBackupService, BackupService>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton<IFileSystem, ManagedFileSystem>();
|
serviceCollection.AddSingleton<IFileSystem, ManagedFileSystem>();
|
||||||
serviceCollection.AddSingleton<IShortcutHandler, MbLinkShortcutHandler>();
|
serviceCollection.AddSingleton<IShortcutHandler, MbLinkShortcutHandler>();
|
||||||
|
127
Jellyfin.Api/Controllers/BackupController.cs
Normal file
127
Jellyfin.Api/Controllers/BackupController.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -5,7 +5,6 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Mime;
|
using System.Net.Mime;
|
||||||
using Jellyfin.Api.Attributes;
|
using Jellyfin.Api.Attributes;
|
||||||
using Jellyfin.Api.Constants;
|
|
||||||
using Jellyfin.Api.Models.SystemInfoDtos;
|
using Jellyfin.Api.Models.SystemInfoDtos;
|
||||||
using MediaBrowser.Common.Api;
|
using MediaBrowser.Common.Api;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
|
@ -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; }
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -7,8 +7,10 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Emby.Server.Implementations.Serialization;
|
using Emby.Server.Implementations.Serialization;
|
||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Server.Implementations.SystemBackupService;
|
||||||
using Jellyfin.Server.Migrations.Stages;
|
using Jellyfin.Server.Migrations.Stages;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller.SystemBackupService;
|
||||||
using MediaBrowser.Model.Configuration;
|
using MediaBrowser.Model.Configuration;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
@ -103,6 +105,8 @@ internal class JellyfinMigrationService
|
|||||||
if (migrationOptions != null && migrationOptions.Applied.Count > 0)
|
if (migrationOptions != null && migrationOptions.Applied.Count > 0)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Old migration style migration.xml detected. Migrate now.");
|
logger.LogInformation("Old migration style migration.xml detected. Migrate now.");
|
||||||
|
try
|
||||||
|
{
|
||||||
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
@ -124,6 +128,12 @@ internal class JellyfinMigrationService
|
|||||||
File.Move(migrationConfigPath, Path.ChangeExtension(migrationConfigPath, ".xml.backup"), true);
|
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];
|
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
|
||||||
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
|
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
|
||||||
var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
|
var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
|
||||||
|
|
||||||
foreach (var item in migrations)
|
foreach (var item in migrations)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
@ -16,7 +16,9 @@ using Jellyfin.Server.Extensions;
|
|||||||
using Jellyfin.Server.Helpers;
|
using Jellyfin.Server.Helpers;
|
||||||
using Jellyfin.Server.Implementations.DatabaseConfiguration;
|
using Jellyfin.Server.Implementations.DatabaseConfiguration;
|
||||||
using Jellyfin.Server.Implementations.Extensions;
|
using Jellyfin.Server.Implementations.Extensions;
|
||||||
|
using Jellyfin.Server.Implementations.FullSystemBackup;
|
||||||
using Jellyfin.Server.Implementations.StorageHelpers;
|
using Jellyfin.Server.Implementations.StorageHelpers;
|
||||||
|
using Jellyfin.Server.Implementations.SystemBackupService;
|
||||||
using Jellyfin.Server.Migrations;
|
using Jellyfin.Server.Migrations;
|
||||||
using Jellyfin.Server.ServerSetupApp;
|
using Jellyfin.Server.ServerSetupApp;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
@ -58,6 +60,7 @@ namespace Jellyfin.Server
|
|||||||
private static long _startTimestamp;
|
private static long _startTimestamp;
|
||||||
private static ILogger _logger = NullLogger.Instance;
|
private static ILogger _logger = NullLogger.Instance;
|
||||||
private static bool _restartOnShutdown;
|
private static bool _restartOnShutdown;
|
||||||
|
private static string? _restoreFromBackup;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The entry point of the application.
|
/// The entry point of the application.
|
||||||
@ -79,6 +82,7 @@ namespace Jellyfin.Server
|
|||||||
|
|
||||||
private static async Task StartApp(StartupOptions options)
|
private static async Task StartApp(StartupOptions options)
|
||||||
{
|
{
|
||||||
|
_restoreFromBackup = options.RestoreArchive;
|
||||||
_startTimestamp = Stopwatch.GetTimestamp();
|
_startTimestamp = Stopwatch.GetTimestamp();
|
||||||
ServerApplicationPaths appPaths = StartupHelpers.CreateApplicationPaths(options);
|
ServerApplicationPaths appPaths = StartupHelpers.CreateApplicationPaths(options);
|
||||||
appPaths.MakeSanityCheckOrThrow();
|
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.
|
// Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection.
|
||||||
appHost.ServiceProvider = _jellyfinHost.Services;
|
appHost.ServiceProvider = _jellyfinHost.Services;
|
||||||
|
|
||||||
PrepareDatabaseProvider(appHost.ServiceProvider);
|
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 ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.CoreInitialisaition).ConfigureAwait(false);
|
||||||
|
|
||||||
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
|
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
|
||||||
@ -209,6 +220,7 @@ namespace Jellyfin.Server
|
|||||||
|
|
||||||
await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false);
|
await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false);
|
||||||
_restartOnShutdown = appHost.ShouldRestart;
|
_restartOnShutdown = appHost.ShouldRestart;
|
||||||
|
_restoreFromBackup = appHost.RestoreBackupPath;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
@ -73,6 +73,12 @@ namespace Jellyfin.Server
|
|||||||
[Option("nonetchange", Required = false, HelpText = "Indicates that the server should not detect network status change.")]
|
[Option("nonetchange", Required = false, HelpText = "Indicates that the server should not detect network status change.")]
|
||||||
public bool NoDetectNetworkChange { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Gets the command line options as a dictionary that can be used in the .NET configuration system.
|
/// Gets the command line options as a dictionary that can be used in the .NET configuration system.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -91,6 +91,12 @@ namespace MediaBrowser.Common.Configuration
|
|||||||
/// <value>The trickplay path.</value>
|
/// <value>The trickplay path.</value>
|
||||||
string TrickplayPath { get; }
|
string TrickplayPath { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the path used for storing backup archives.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The backup path.</value>
|
||||||
|
string BackupPath { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks and creates all known base paths.
|
/// Checks and creates all known base paths.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -38,6 +38,11 @@ namespace MediaBrowser.Controller
|
|||||||
/// <value>The name of the friendly.</value>
|
/// <value>The name of the friendly.</value>
|
||||||
string FriendlyName { get; }
|
string FriendlyName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the path to the backup archive used to restore upon restart.
|
||||||
|
/// </summary>
|
||||||
|
string RestoreBackupPath { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a URL specific for the request.
|
/// Gets a URL specific for the request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -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; }
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
@ -14,7 +14,6 @@ public class TrickplayInfo
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Required.
|
/// Required.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonIgnore]
|
|
||||||
public Guid ItemId { get; set; }
|
public Guid ItemId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -61,7 +61,6 @@ namespace Jellyfin.Database.Implementations.Entities
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Identity, Indexed, Required.
|
/// Identity, Indexed, Required.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonIgnore]
|
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@ -62,4 +63,12 @@ public interface IJellyfinDatabaseProvider
|
|||||||
/// <param name="cancellationToken">A cancellation token.</param>
|
/// <param name="cancellationToken">A cancellation token.</param>
|
||||||
/// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
|
/// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
|
||||||
Task RestoreBackupFast(string key, CancellationToken cancellationToken);
|
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);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@ -82,7 +83,7 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run before disposing the application
|
// 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 using (context.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).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);
|
File.Copy(backupFile, path, true);
|
||||||
return Task.CompletedTask;
|
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,4 +1,5 @@
|
|||||||
using Jellyfin.Api.Controllers;
|
using Jellyfin.Api.Controllers;
|
||||||
|
using Jellyfin.Server.Implementations.SystemBackupService;
|
||||||
using MediaBrowser.Common.Net;
|
using MediaBrowser.Common.Net;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user