< Summary - Jellyfin

Information
Class: Jellyfin.Server.Implementations.FullSystemBackup.BackupService
Assembly: Jellyfin.Server.Implementations
File(s): /srv/git/jellyfin/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
Line coverage
17%
Covered lines: 8
Uncovered lines: 39
Coverable lines: 47
Total lines: 512
Line coverage: 17%
Branch coverage
0%
Covered branches: 0
Total branches: 2
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%11100%
ScheduleRestoreAndRestartServer(...)100%210%
TestBackupVersionCompatibility(...)0%620%
Map(...)100%210%
Map(...)100%210%
Map(...)100%210%

File(s)

/srv/git/jellyfin/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.IO;
 4using System.IO.Compression;
 5using System.Linq;
 6using System.Text.Json;
 7using System.Text.Json.Nodes;
 8using System.Text.Json.Serialization;
 9using System.Threading;
 10using System.Threading.Tasks;
 11using Jellyfin.Database.Implementations;
 12using Jellyfin.Server.Implementations.StorageHelpers;
 13using Jellyfin.Server.Implementations.SystemBackupService;
 14using MediaBrowser.Controller;
 15using MediaBrowser.Controller.SystemBackupService;
 16using Microsoft.EntityFrameworkCore;
 17using Microsoft.EntityFrameworkCore.Infrastructure;
 18using Microsoft.EntityFrameworkCore.Migrations;
 19using Microsoft.Extensions.Hosting;
 20using Microsoft.Extensions.Logging;
 21
 22namespace Jellyfin.Server.Implementations.FullSystemBackup;
 23
 24/// <summary>
 25/// Contains methods for creating and restoring backups.
 26/// </summary>
 27public class BackupService : IBackupService
 28{
 29    private const string ManifestEntryName = "manifest.json";
 30    private readonly ILogger<BackupService> _logger;
 31    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 32    private readonly IServerApplicationHost _applicationHost;
 33    private readonly IServerApplicationPaths _applicationPaths;
 34    private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
 35    private readonly IHostApplicationLifetime _hostApplicationLifetime;
 036    private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults
 037    {
 038        AllowTrailingCommas = true,
 039        ReferenceHandler = ReferenceHandler.IgnoreCycles,
 040    };
 41
 2142    private readonly Version _backupEngineVersion = Version.Parse("0.1.0");
 43
 44    /// <summary>
 45    /// Initializes a new instance of the <see cref="BackupService"/> class.
 46    /// </summary>
 47    /// <param name="logger">A logger.</param>
 48    /// <param name="dbProvider">A Database Factory.</param>
 49    /// <param name="applicationHost">The Application host.</param>
 50    /// <param name="applicationPaths">The application paths.</param>
 51    /// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
 52    /// <param name="applicationLifetime">The SystemManager.</param>
 53    public BackupService(
 54        ILogger<BackupService> logger,
 55        IDbContextFactory<JellyfinDbContext> dbProvider,
 56        IServerApplicationHost applicationHost,
 57        IServerApplicationPaths applicationPaths,
 58        IJellyfinDatabaseProvider jellyfinDatabaseProvider,
 59        IHostApplicationLifetime applicationLifetime)
 60    {
 2161        _logger = logger;
 2162        _dbProvider = dbProvider;
 2163        _applicationHost = applicationHost;
 2164        _applicationPaths = applicationPaths;
 2165        _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
 2166        _hostApplicationLifetime = applicationLifetime;
 2167    }
 68
 69    /// <inheritdoc/>
 70    public void ScheduleRestoreAndRestartServer(string archivePath)
 71    {
 072        _applicationHost.RestoreBackupPath = archivePath;
 073        _applicationHost.ShouldRestart = true;
 074        _applicationHost.NotifyPendingRestart();
 075        _ = Task.Run(async () =>
 076        {
 077            await Task.Delay(500).ConfigureAwait(false);
 078            _hostApplicationLifetime.StopApplication();
 079        });
 080    }
 81
 82    /// <inheritdoc/>
 83    public async Task RestoreBackupAsync(string archivePath)
 84    {
 85        _logger.LogWarning("Begin restoring system to {BackupArchive}", archivePath); // Info isn't cutting it
 86        if (!File.Exists(archivePath))
 87        {
 88            throw new FileNotFoundException($"Requested backup file '{archivePath}' does not exist.");
 89        }
 90
 91        StorageHelper.TestCommonPathsForStorageCapacity(_applicationPaths, _logger);
 92
 93        var fileStream = File.OpenRead(archivePath);
 94        await using (fileStream.ConfigureAwait(false))
 95        {
 96            using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read, false);
 97            var zipArchiveEntry = zipArchive.GetEntry(ManifestEntryName);
 98
 99            if (zipArchiveEntry is null)
 100            {
 101                throw new NotSupportedException($"The loaded archive '{archivePath}' does not appear to be a Jellyfin ba
 102            }
 103
 104            BackupManifest? manifest;
 105            var manifestStream = zipArchiveEntry.Open();
 106            await using (manifestStream.ConfigureAwait(false))
 107            {
 108                manifest = await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).Co
 109            }
 110
 111            if (manifest!.ServerVersion > _applicationHost.ApplicationVersion) // newer versions of Jellyfin should be a
 112            {
 113                throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jell
 114            }
 115
 116            if (!TestBackupVersionCompatibility(manifest.BackupEngineVersion))
 117            {
 118                throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jell
 119            }
 120
 121            void CopyDirectory(string source, string target)
 122            {
 123                source = Path.GetFullPath(source);
 124                Directory.CreateDirectory(source);
 125
 126                foreach (var item in zipArchive.Entries)
 127                {
 128                    var sanitizedSourcePath = Path.GetFullPath(item.FullName.TrimEnd(Path.DirectorySeparatorChar) + Path
 129                    if (!sanitizedSourcePath.StartsWith(target, StringComparison.Ordinal))
 130                    {
 131                        continue;
 132                    }
 133
 134                    var targetPath = Path.Combine(source, sanitizedSourcePath[target.Length..].Trim('/'));
 135                    _logger.LogInformation("Restore and override {File}", targetPath);
 136                    item.ExtractToFile(targetPath);
 137                }
 138            }
 139
 140            CopyDirectory(_applicationPaths.ConfigurationDirectoryPath, "Config/");
 141            CopyDirectory(_applicationPaths.DataPath, "Data/");
 142            CopyDirectory(_applicationPaths.RootFolderPath, "Root/");
 143
 144            if (manifest.Options.Database)
 145            {
 146                _logger.LogInformation("Begin restoring Database");
 147                var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 148                await using (dbContext.ConfigureAwait(false))
 149                {
 150                    // restore migration history manually
 151                    var historyEntry = zipArchive.GetEntry($"Database\\{nameof(HistoryRow)}.json");
 152                    if (historyEntry is null)
 153                    {
 154                        _logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin
 155                        throw new InvalidOperationException("Cannot restore backup that has no History data.");
 156                    }
 157
 158                    HistoryRow[] historyEntries;
 159                    var historyArchive = historyEntry.Open();
 160                    await using (historyArchive.ConfigureAwait(false))
 161                    {
 162                        historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAw
 163                            throw new InvalidOperationException("Cannot restore backup that has no History data.");
 164                    }
 165
 166                    var historyRepository = dbContext.GetService<IHistoryRepository>();
 167                    await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
 168                    foreach (var item in historyEntries)
 169                    {
 170                        var insertScript = historyRepository.GetInsertScript(item);
 171                        await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
 172                    }
 173
 174                    dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
 175                    var entityTypes = typeof(JellyfinDbContext).GetProperties(System.Reflection.BindingFlags.Public | Sy
 176                        .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
 177                        .Select(e => (Type: e, Set: e.GetValue(dbContext) as IQueryable))
 178                        .ToArray();
 179
 180                    var tableNames = entityTypes.Select(f => dbContext.Model.FindEntityType(f.Type.PropertyType.GetGener
 181                    _logger.LogInformation("Begin purging database");
 182                    await _jellyfinDatabaseProvider.PurgeDatabase(dbContext, tableNames).ConfigureAwait(false);
 183                    _logger.LogInformation("Database Purged");
 184
 185                    foreach (var entityType in entityTypes)
 186                    {
 187                        _logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
 188
 189                        var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json");
 190                        if (zipEntry is null)
 191                        {
 192                            _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue a
 193                            continue;
 194                        }
 195
 196                        var zipEntryStream = zipEntry.Open();
 197                        await using (zipEntryStream.ConfigureAwait(false))
 198                        {
 199                            _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
 200                            var records = 0;
 201                            await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStr
 202                            {
 203                                var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
 204                                if (entity is null)
 205                                {
 206                                    throw new InvalidOperationException($"Cannot deserialize entity '{item}'");
 207                                }
 208
 209                                try
 210                                {
 211                                    records++;
 212                                    dbContext.Add(entity);
 213                                }
 214                                catch (Exception ex)
 215                                {
 216                                    _logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item);
 217                                }
 218                            }
 219
 220                            _logger.LogInformation("Prepared to restore {Number} entries for {Table}", records, entityTy
 221                        }
 222                    }
 223
 224                    _logger.LogInformation("Try restore Database");
 225                    await dbContext.SaveChangesAsync().ConfigureAwait(false);
 226                    _logger.LogInformation("Restored database.");
 227                }
 228            }
 229
 230            _logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated);
 231        }
 232    }
 233
 234    private bool TestBackupVersionCompatibility(Version backupEngineVersion)
 235    {
 0236        if (backupEngineVersion == _backupEngineVersion)
 237        {
 0238            return true;
 239        }
 240
 0241        return false;
 242    }
 243
 244    /// <inheritdoc/>
 245    public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
 246    {
 247        var manifest = new BackupManifest()
 248        {
 249            DateCreated = DateTime.UtcNow,
 250            ServerVersion = _applicationHost.ApplicationVersion,
 251            DatabaseTables = null!,
 252            BackupEngineVersion = _backupEngineVersion,
 253            Options = Map(backupOptions)
 254        };
 255
 256        await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
 257
 258        var backupFolder = Path.Combine(_applicationPaths.BackupPath);
 259
 260        if (!Directory.Exists(backupFolder))
 261        {
 262            Directory.CreateDirectory(backupFolder);
 263        }
 264
 265        var backupStorageSpace = StorageHelper.GetFreeSpaceOf(_applicationPaths.BackupPath);
 266
 267        const long FiveGigabyte = 5_368_709_115;
 268        if (backupStorageSpace.FreeSpace < FiveGigabyte)
 269        {
 270            throw new InvalidOperationException($"The backup directory '{backupStorageSpace.Path}' does not have at leas
 271        }
 272
 273        var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss
 274        _logger.LogInformation("Attempt to create a new backup at {BackupPath}", backupPath);
 275        var fileStream = File.OpenWrite(backupPath);
 276        await using (fileStream.ConfigureAwait(false))
 277        using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
 278        {
 279            _logger.LogInformation("Start backup process.");
 280            var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 281            await using (dbContext.ConfigureAwait(false))
 282            {
 283                dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
 284                static IAsyncEnumerable<object> GetValues(IQueryable dbSet, Type type)
 285                {
 286                    var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
 287                    var enumerable = method.Invoke(dbSet, null)!;
 288                    return (IAsyncEnumerable<object>)enumerable;
 289                }
 290
 291                // include the migration history as well
 292                var historyRepository = dbContext.GetService<IHistoryRepository>();
 293                var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
 294
 295                ICollection<(Type Type, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [
 296                    .. typeof(JellyfinDbContext)
 297                    .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
 298                    .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
 299                    .Select(e => (Type: e.PropertyType, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues
 300                    (Type: typeof(HistoryRow), ValueFactory: new Func<IAsyncEnumerable<object>>(() => migrations.ToAsync
 301                ];
 302                manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
 303                var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
 304
 305                await using (transaction.ConfigureAwait(false))
 306                {
 307                    _logger.LogInformation("Begin Database backup");
 308
 309                    foreach (var entityType in entityTypes)
 310                    {
 311                        _logger.LogInformation("Begin backup of entity {Table}", entityType.Type.Name);
 312                        var zipEntry = zipArchive.CreateEntry($"Database\\{entityType.Type.Name}.json");
 313                        var entities = 0;
 314                        var zipEntryStream = zipEntry.Open();
 315                        await using (zipEntryStream.ConfigureAwait(false))
 316                        {
 317                            var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
 318                            await using (jsonSerializer.ConfigureAwait(false))
 319                            {
 320                                jsonSerializer.WriteStartArray();
 321
 322                                var set = entityType.ValueFactory().ConfigureAwait(false);
 323                                await foreach (var item in set.ConfigureAwait(false))
 324                                {
 325                                    entities++;
 326                                    try
 327                                    {
 328                                        JsonSerializer.SerializeToDocument(item, _serializerSettings).WriteTo(jsonSerial
 329                                    }
 330                                    catch (Exception ex)
 331                                    {
 332                                        _logger.LogError(ex, "Could not load entity {Entity}", item);
 333                                        throw;
 334                                    }
 335                                }
 336
 337                                jsonSerializer.WriteEndArray();
 338                            }
 339                        }
 340
 341                        _logger.LogInformation("backup of entity {Table} with {Number} created", entityType.Type.Name, e
 342                    }
 343                }
 344            }
 345
 346            _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
 347            foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchO
 348              .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDi
 349            {
 350                zipArchive.CreateEntryFromFile(item, Path.Combine("Config", Path.GetFileName(item)));
 351            }
 352
 353            void CopyDirectory(string source, string target, string filter = "*")
 354            {
 355                if (!Directory.Exists(source))
 356                {
 357                    return;
 358                }
 359
 360                _logger.LogInformation("Backup of folder {Table}", source);
 361
 362                foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
 363                {
 364                    zipArchive.CreateEntryFromFile(item, Path.Combine(target, item[..source.Length].Trim('\\')));
 365                }
 366            }
 367
 368            CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "u
 369            CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Co
 370            CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
 371            CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
 372            CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
 373            CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTas
 374            if (backupOptions.Subtitles)
 375            {
 376                CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
 377            }
 378
 379            if (backupOptions.Trickplay)
 380            {
 381                CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
 382            }
 383
 384            if (backupOptions.Metadata)
 385            {
 386                CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
 387            }
 388
 389            var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
 390            await using (manifestStream.ConfigureAwait(false))
 391            {
 392                await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
 393            }
 394        }
 395
 396        _logger.LogInformation("Backup created");
 397        return Map(manifest, backupPath);
 398    }
 399
 400    /// <inheritdoc/>
 401    public async Task<BackupManifestDto?> GetBackupManifest(string archivePath)
 402    {
 403        if (!File.Exists(archivePath))
 404        {
 405            return null;
 406        }
 407
 408        BackupManifest? manifest;
 409        try
 410        {
 411            manifest = await GetManifest(archivePath).ConfigureAwait(false);
 412        }
 413        catch (Exception ex)
 414        {
 415            _logger.LogError(ex, "Tried to load archive from {Path} but failed.", archivePath);
 416            return null;
 417        }
 418
 419        if (manifest is null)
 420        {
 421            return null;
 422        }
 423
 424        return Map(manifest, archivePath);
 425    }
 426
 427    /// <inheritdoc/>
 428    public async Task<BackupManifestDto[]> EnumerateBackups()
 429    {
 430        if (!Directory.Exists(_applicationPaths.BackupPath))
 431        {
 432            return [];
 433        }
 434
 435        var archives = Directory.EnumerateFiles(_applicationPaths.BackupPath, "*.zip");
 436        var manifests = new List<BackupManifestDto>();
 437        foreach (var item in archives)
 438        {
 439            try
 440            {
 441                var manifest = await GetManifest(item).ConfigureAwait(false);
 442
 443                if (manifest is null)
 444                {
 445                    continue;
 446                }
 447
 448                manifests.Add(Map(manifest, item));
 449            }
 450            catch (Exception ex)
 451            {
 452                _logger.LogError(ex, "Could not load {BackupArchive} path.", item);
 453            }
 454        }
 455
 456        return manifests.ToArray();
 457    }
 458
 459    private static async ValueTask<BackupManifest?> GetManifest(string archivePath)
 460    {
 461        var archiveStream = File.OpenRead(archivePath);
 462        await using (archiveStream.ConfigureAwait(false))
 463        {
 464            using var zipStream = new ZipArchive(archiveStream, ZipArchiveMode.Read);
 465            var manifestEntry = zipStream.GetEntry(ManifestEntryName);
 466            if (manifestEntry is null)
 467            {
 468                return null;
 469            }
 470
 471            var manifestStream = manifestEntry.Open();
 472            await using (manifestStream.ConfigureAwait(false))
 473            {
 474                return await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).Config
 475            }
 476        }
 477    }
 478
 479    private static BackupManifestDto Map(BackupManifest manifest, string path)
 480    {
 0481        return new BackupManifestDto()
 0482        {
 0483            BackupEngineVersion = manifest.BackupEngineVersion,
 0484            DateCreated = manifest.DateCreated,
 0485            ServerVersion = manifest.ServerVersion,
 0486            Path = path,
 0487            Options = Map(manifest.Options)
 0488        };
 489    }
 490
 491    private static BackupOptionsDto Map(BackupOptions options)
 492    {
 0493        return new BackupOptionsDto()
 0494        {
 0495            Metadata = options.Metadata,
 0496            Subtitles = options.Subtitles,
 0497            Trickplay = options.Trickplay,
 0498            Database = options.Database
 0499        };
 500    }
 501
 502    private static BackupOptions Map(BackupOptionsDto options)
 503    {
 0504        return new BackupOptions()
 0505        {
 0506            Metadata = options.Metadata,
 0507            Subtitles = options.Subtitles,
 0508            Trickplay = options.Trickplay,
 0509            Database = options.Database
 0510        };
 511    }
 512}