< 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
44%
Covered lines: 123
Uncovered lines: 156
Coverable lines: 279
Total lines: 611
Line coverage: 44%
Branch coverage
30%
Covered branches: 22
Total branches: 72
Branch coverage: 30.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 4/15/2026 - 12:14:34 AM Line coverage: 16.6% (8/48) Branch coverage: 0% (0/2) Total lines: 5774/19/2026 - 12:14:27 AM Line coverage: 3% (8/266) Branch coverage: 0% (0/70) Total lines: 5776/28/2026 - 12:15:35 AM Line coverage: 3.3% (9/270) Branch coverage: 0% (0/72) Total lines: 5907/22/2026 - 12:16:22 AM Line coverage: 44% (123/279) Branch coverage: 30.5% (22/72) Total lines: 611 4/15/2026 - 12:14:34 AM Line coverage: 16.6% (8/48) Branch coverage: 0% (0/2) Total lines: 5774/19/2026 - 12:14:27 AM Line coverage: 3% (8/266) Branch coverage: 0% (0/70) Total lines: 5776/28/2026 - 12:15:35 AM Line coverage: 3.3% (9/270) Branch coverage: 0% (0/72) Total lines: 5907/22/2026 - 12:16:22 AM Line coverage: 44% (123/279) Branch coverage: 30.5% (22/72) Total lines: 611

Coverage delta

Coverage delta 41 -41

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
ScheduleRestoreAndRestartServer(...)100%210%
RestoreBackupAsync()0%702260%
TestBackupVersionCompatibility(...)0%620%
CreateBackupAsync()68.75%443277.47%
GetBackupManifest()0%2040%
EnumerateBackups()0%4260%
GetManifest()0%620%
Map(...)100%11100%
Map(...)100%11100%
Map(...)100%11100%
NormalizePathSeparator(...)100%11100%

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.Library;
 16using MediaBrowser.Controller.SystemBackupService;
 17using Microsoft.EntityFrameworkCore;
 18using Microsoft.EntityFrameworkCore.Infrastructure;
 19using Microsoft.EntityFrameworkCore.Migrations;
 20using Microsoft.Extensions.Hosting;
 21using Microsoft.Extensions.Logging;
 22
 23namespace Jellyfin.Server.Implementations.FullSystemBackup;
 24
 25/// <summary>
 26/// Contains methods for creating and restoring backups.
 27/// </summary>
 28public class BackupService : IBackupService
 29{
 30    private const string ManifestEntryName = "manifest.json";
 31    private readonly ILogger<BackupService> _logger;
 32    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 33    private readonly IServerApplicationHost _applicationHost;
 34    private readonly IServerApplicationPaths _applicationPaths;
 35    private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
 36    private readonly IHostApplicationLifetime _hostApplicationLifetime;
 37    private readonly ILibraryManager _libraryManager;
 138    private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults
 139    {
 140        AllowTrailingCommas = true,
 141        ReferenceHandler = ReferenceHandler.IgnoreCycles,
 142    };
 43
 2344    private readonly Version _backupEngineVersion = new Version(0, 2, 0);
 45
 46    /// <summary>
 47    /// Initializes a new instance of the <see cref="BackupService"/> class.
 48    /// </summary>
 49    /// <param name="logger">A logger.</param>
 50    /// <param name="dbProvider">A Database Factory.</param>
 51    /// <param name="applicationHost">The Application host.</param>
 52    /// <param name="applicationPaths">The application paths.</param>
 53    /// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
 54    /// <param name="applicationLifetime">The SystemManager.</param>
 55    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
 56    public BackupService(
 57        ILogger<BackupService> logger,
 58        IDbContextFactory<JellyfinDbContext> dbProvider,
 59        IServerApplicationHost applicationHost,
 60        IServerApplicationPaths applicationPaths,
 61        IJellyfinDatabaseProvider jellyfinDatabaseProvider,
 62        IHostApplicationLifetime applicationLifetime,
 63        ILibraryManager libraryManager)
 64    {
 2365        _logger = logger;
 2366        _dbProvider = dbProvider;
 2367        _applicationHost = applicationHost;
 2368        _applicationPaths = applicationPaths;
 2369        _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
 2370        _hostApplicationLifetime = applicationLifetime;
 2371        _libraryManager = libraryManager;
 2372    }
 73
 74    /// <inheritdoc/>
 75    public void ScheduleRestoreAndRestartServer(string archivePath)
 76    {
 077        _applicationHost.RestoreBackupPath = archivePath;
 078        _applicationHost.ShouldRestart = true;
 079        _applicationHost.NotifyPendingRestart();
 080        _ = Task.Run(async () =>
 081        {
 082            await Task.Delay(500).ConfigureAwait(false);
 083            _hostApplicationLifetime.StopApplication();
 084        });
 085    }
 86
 87    /// <inheritdoc/>
 88    public async Task RestoreBackupAsync(string archivePath)
 89    {
 090        _logger.LogWarning("Begin restoring system to {BackupArchive}", archivePath); // Info isn't cutting it
 091        if (!File.Exists(archivePath))
 92        {
 093            throw new FileNotFoundException($"Requested backup file '{archivePath}' does not exist.");
 94        }
 95
 096        StorageHelper.TestCommonPathsForStorageCapacity(_applicationPaths, _logger);
 97
 098        var fileStream = File.OpenRead(archivePath);
 099        await using (fileStream.ConfigureAwait(false))
 100        {
 0101            using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read, false);
 0102            var zipArchiveEntry = zipArchive.GetEntry(ManifestEntryName);
 103
 0104            if (zipArchiveEntry is null)
 105            {
 0106                throw new NotSupportedException($"The loaded archive '{archivePath}' does not appear to be a Jellyfin ba
 107            }
 108
 109            BackupManifest? manifest;
 0110            var manifestStream = await zipArchiveEntry.OpenAsync().ConfigureAwait(false);
 0111            await using (manifestStream.ConfigureAwait(false))
 112            {
 0113                manifest = await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).Co
 114            }
 115
 0116            if (manifest!.ServerVersion > _applicationHost.ApplicationVersion) // newer versions of Jellyfin should be a
 117            {
 0118                throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jell
 119            }
 120
 0121            if (!TestBackupVersionCompatibility(manifest.BackupEngineVersion))
 122            {
 0123                throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jell
 124            }
 125
 126            void CopyDirectory(string source, string target, string[]? exclude = null)
 127            {
 128                var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
 129                var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
 130                var excludePaths = exclude?.Select(e => $"{source}/{e}/").ToArray();
 131                foreach (var item in zipArchive.Entries)
 132                {
 133                    var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
 134                    var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)))
 135
 136                    if (excludePaths is not null && excludePaths.Any(e => item.FullName.StartsWith(e, StringComparison.O
 137                    {
 138                        continue;
 139                    }
 140
 141                    if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
 142                        || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
 143                        || Path.EndsInDirectorySeparator(item.FullName))
 144                    {
 145                        continue;
 146                    }
 147
 148                    _logger.LogInformation("Restore and override {File}", targetPath);
 149
 150                    Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
 151                    item.ExtractToFile(targetPath, overwrite: true);
 152                }
 153            }
 154
 0155            CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
 0156            CopyDirectory("Data", _applicationPaths.DataPath, exclude: ["metadata", "metadata-default"]);
 0157            CopyDirectory("Root", _applicationPaths.RootFolderPath);
 0158            CopyDirectory("Data/metadata", _applicationPaths.InternalMetadataPath);
 0159            CopyDirectory("Data/metadata-default", _applicationPaths.DefaultInternalMetadataPath);
 160
 0161            if (manifest.Options.Database)
 162            {
 0163                _logger.LogInformation("Begin restoring Database");
 0164                var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 0165                await using (dbContext.ConfigureAwait(false))
 166                {
 167                    // restore migration history manually
 0168                    var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{nameof(His
 0169                    if (historyEntry is null)
 170                    {
 0171                        _logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin
 0172                        throw new InvalidOperationException("Cannot restore backup that has no History data.");
 173                    }
 174
 175                    HistoryRow[] historyEntries;
 0176                    var historyArchive = await historyEntry.OpenAsync().ConfigureAwait(false);
 0177                    await using (historyArchive.ConfigureAwait(false))
 178                    {
 0179                        historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAw
 0180                            throw new InvalidOperationException("Cannot restore backup that has no History data.");
 181                    }
 182
 0183                    var historyRepository = dbContext.GetService<IHistoryRepository>();
 0184                    await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
 185
 0186                    foreach (var item in await historyRepository.GetAppliedMigrationsAsync(CancellationToken.None).Confi
 187                    {
 0188                        var insertScript = historyRepository.GetDeleteScript(item.MigrationId);
 0189                        await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
 190                    }
 191
 0192                    foreach (var item in historyEntries)
 193                    {
 0194                        var insertScript = historyRepository.GetInsertScript(item);
 0195                        await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
 196                    }
 197
 0198                    dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
 0199                    var entityTypes = typeof(JellyfinDbContext).GetProperties(System.Reflection.BindingFlags.Public | Sy
 0200                        .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
 0201                        .Select(e => (Type: e, Set: e.GetValue(dbContext) as IQueryable))
 0202                        .ToArray();
 203
 0204                    var tableNames = entityTypes.Select(f => dbContext.Model.FindEntityType(f.Type.PropertyType.GetGener
 0205                    _logger.LogInformation("Begin purging database");
 0206                    await _jellyfinDatabaseProvider.PurgeDatabase(dbContext, tableNames).ConfigureAwait(false);
 0207                    _logger.LogInformation("Database Purged");
 208
 0209                    foreach (var entityType in entityTypes)
 210                    {
 0211                        _logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
 212
 0213                        var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType
 0214                        if (zipEntry is null)
 215                        {
 0216                            _logger.LogInformation("No backup of expected table {Table} is present in backup, continuing
 0217                            continue;
 218                        }
 219
 0220                        var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false);
 0221                        await using (zipEntryStream.ConfigureAwait(false))
 222                        {
 0223                            _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
 0224                            var records = 0;
 0225                            await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStr
 226                            {
 0227                                var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
 0228                                if (entity is null)
 229                                {
 0230                                    throw new InvalidOperationException($"Cannot deserialize entity '{item}'");
 231                                }
 232
 233                                try
 234                                {
 0235                                    records++;
 0236                                    dbContext.Add(entity);
 0237                                }
 0238                                catch (Exception ex)
 239                                {
 0240                                    _logger.LogError(ex, "Could not store entity {Entity}, continuing anyway", item);
 0241                                }
 242                            }
 243
 0244                            _logger.LogInformation("Prepared to restore {Number} entries for {Table}", records, entityTy
 245                        }
 0246                    }
 247
 0248                    _logger.LogInformation("Try restore Database");
 0249                    await dbContext.SaveChangesAsync().ConfigureAwait(false);
 0250                    _logger.LogInformation("Restored database");
 0251                }
 0252            }
 253
 0254            _logger.LogInformation("Restored Jellyfin system from {Date}", manifest.DateCreated);
 0255        }
 0256    }
 257
 258    private bool TestBackupVersionCompatibility(Version backupEngineVersion)
 259    {
 0260        if (backupEngineVersion == _backupEngineVersion)
 261        {
 0262            return true;
 263        }
 264
 0265        return false;
 266    }
 267
 268    /// <inheritdoc/>
 269    public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
 270    {
 271        // Creating a backup runs a database optimization and reads the entire database under a transaction, both of
 272        // which heavily contend with an active library scan and could capture an inconsistent database state.
 1273        if (_libraryManager.IsScanRunning)
 274        {
 0275            _logger.LogWarning("Cannot create a backup while a library scan is running.");
 0276            throw new InvalidOperationException("Cannot create a backup while a library scan is running. Please try agai
 277        }
 278
 1279        var manifest = new BackupManifest()
 1280        {
 1281            DateCreated = DateTime.UtcNow,
 1282            ServerVersion = _applicationHost.ApplicationVersion,
 1283            DatabaseTables = null!,
 1284            BackupEngineVersion = _backupEngineVersion,
 1285            Options = Map(backupOptions)
 1286        };
 287
 1288        _logger.LogInformation("Running database optimization before backup");
 289
 1290        await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
 291
 1292        var backupFolder = Path.Combine(_applicationPaths.BackupPath);
 293
 1294        if (!Directory.Exists(backupFolder))
 295        {
 0296            Directory.CreateDirectory(backupFolder);
 297        }
 298
 1299        var backupStorageSpace = StorageHelper.GetFreeSpaceOf(_applicationPaths.BackupPath);
 300
 301        const long FiveGigabyte = 5_368_709_115;
 1302        if (backupStorageSpace.FreeSpace < FiveGigabyte)
 303        {
 0304            throw new InvalidOperationException($"The backup directory '{backupStorageSpace.Path}' does not have at leas
 305        }
 306
 1307        var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss
 308
 309        try
 310        {
 1311            _logger.LogInformation("Attempting to create a new backup at {BackupPath}", backupPath);
 1312            var fileStream = File.OpenWrite(backupPath);
 1313            await using (fileStream.ConfigureAwait(false))
 1314            using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
 315            {
 1316                _logger.LogInformation("Starting backup process");
 1317                var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 1318                await using (dbContext.ConfigureAwait(false))
 319                {
 1320                    dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
 321
 322                    static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
 323                    {
 324                        var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
 325                        var enumerable = method.Invoke(dbSet, null)!;
 326                        return (IAsyncEnumerable<object>)enumerable;
 327                    }
 328
 329                    // include the migration history as well
 1330                    var historyRepository = dbContext.GetService<IHistoryRepository>();
 1331                    var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
 332
 1333                    ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes
 1334                    [
 1335                        .. typeof(JellyfinDbContext)
 1336                            .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instan
 1337                            .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
 1338                            .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGeneric
 1339                        (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyn
 1340                    ];
 1341                    manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
 1342                    var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
 343
 1344                    await using (transaction.ConfigureAwait(false))
 345                    {
 1346                        _logger.LogInformation("Begin Database backup");
 347
 64348                        foreach (var entityType in entityTypes)
 349                        {
 31350                            _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
 31351                            var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{ent
 31352                            var entities = 0;
 31353                            var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false);
 31354                            await using (zipEntryStream.ConfigureAwait(false))
 355                            {
 31356                                var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
 31357                                await using (jsonSerializer.ConfigureAwait(false))
 358                                {
 31359                                    jsonSerializer.WriteStartArray();
 360
 31361                                    var set = entityType.ValueFactory().ConfigureAwait(false);
 31362                                    var enumerator = set.GetAsyncEnumerator();
 31363                                    await using (enumerator)
 364                                    {
 365                                        while (true)
 366                                        {
 367                                            bool hasNext;
 368                                            try
 369                                            {
 36370                                                hasNext = await enumerator.MoveNextAsync();
 35371                                            }
 1372                                            catch (Exception ex)
 373                                            {
 1374                                                _logger.LogError(ex, "Could not read next entity of type {Table}, the un
 1375                                                continue;
 376                                            }
 377
 35378                                            if (!hasNext)
 379                                            {
 380                                                break;
 381                                            }
 382
 4383                                            var item = enumerator.Current;
 4384                                            entities++;
 385                                            try
 386                                            {
 4387                                                using var document = JsonSerializer.SerializeToDocument(item, _serialize
 4388                                                document.WriteTo(jsonSerializer);
 4389                                            }
 0390                                            catch (Exception ex)
 391                                            {
 0392                                                _logger.LogError(ex, "Could not load entity {Entity}", item);
 0393                                                throw;
 394                                            }
 395                                        }
 396                                    }
 397
 31398                                    jsonSerializer.WriteEndArray();
 31399                                }
 31400                            }
 401
 31402                            _logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceNa
 31403                        }
 404                    }
 1405                }
 406
 1407                _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
 2408                foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", Sea
 1409                             .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", Sea
 410                {
 0411                    await zipArchive.CreateEntryFromFileAsync(item, NormalizePathSeparator(Path.Combine("Config", Path.G
 412                }
 413
 414                void CopyDirectory(string source, string target, string filter = "*")
 415                {
 416                    if (!Directory.Exists(source))
 417                    {
 418                        return;
 419                    }
 420
 421                    _logger.LogInformation("Backup of folder {Table}", source);
 422
 423                    foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
 424                    {
 425                        // TODO: @bond make async
 426                        zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativ
 427                    }
 428                }
 429
 1430                CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config"
 1431                CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine
 1432                CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
 1433                CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections
 1434                CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
 1435                CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "Schedule
 1436                if (backupOptions.Subtitles)
 437                {
 0438                    CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles
 439                }
 440
 1441                if (backupOptions.Trickplay)
 442                {
 0443                    CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay
 444                }
 445
 1446                if (backupOptions.Metadata)
 447                {
 0448                    CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata")
 449
 450                    // If a custom metadata path is configured, the default location may still contain data.
 0451                    if (!string.Equals(
 0452                            Path.GetFullPath(_applicationPaths.DefaultInternalMetadataPath),
 0453                            Path.GetFullPath(_applicationPaths.InternalMetadataPath),
 0454                            StringComparison.OrdinalIgnoreCase))
 455                    {
 0456                        CopyDirectory(Path.Combine(_applicationPaths.DefaultInternalMetadataPath), Path.Combine("Data", 
 457                    }
 458                }
 459
 1460                var manifestStream = await zipArchive.CreateEntry(ManifestEntryName).OpenAsync().ConfigureAwait(false);
 1461                await using (manifestStream.ConfigureAwait(false))
 462                {
 1463                    await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
 464                }
 1465            }
 466
 1467            _logger.LogInformation("Backup created");
 1468            return Map(manifest, backupPath);
 469        }
 0470        catch (Exception ex)
 471        {
 0472            _logger.LogError(ex, "Failed to create backup, removing {BackupPath}", backupPath);
 473            try
 474            {
 0475                if (File.Exists(backupPath))
 476                {
 0477                    File.Delete(backupPath);
 478                }
 0479            }
 0480            catch (Exception innerEx)
 481            {
 0482                _logger.LogWarning(innerEx, "Unable to remove failed backup");
 0483            }
 484
 0485            throw;
 486        }
 1487    }
 488
 489    /// <inheritdoc/>
 490    public async Task<BackupManifestDto?> GetBackupManifest(string archivePath)
 491    {
 0492        if (!File.Exists(archivePath))
 493        {
 0494            return null;
 495        }
 496
 497        BackupManifest? manifest;
 498        try
 499        {
 0500            manifest = await GetManifest(archivePath).ConfigureAwait(false);
 0501        }
 0502        catch (Exception ex)
 503        {
 0504            _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", archivePath);
 0505            return null;
 506        }
 507
 0508        if (manifest is null)
 509        {
 0510            return null;
 511        }
 512
 0513        return Map(manifest, archivePath);
 0514    }
 515
 516    /// <inheritdoc/>
 517    public async Task<BackupManifestDto[]> EnumerateBackups()
 518    {
 0519        if (!Directory.Exists(_applicationPaths.BackupPath))
 520        {
 0521            return [];
 522        }
 523
 0524        var archives = Directory.EnumerateFiles(_applicationPaths.BackupPath, "*.zip");
 0525        var manifests = new List<BackupManifestDto>();
 0526        foreach (var item in archives)
 527        {
 528            try
 529            {
 0530                var manifest = await GetManifest(item).ConfigureAwait(false);
 531
 0532                if (manifest is null)
 533                {
 0534                    continue;
 535                }
 536
 0537                manifests.Add(Map(manifest, item));
 0538            }
 0539            catch (Exception ex)
 540            {
 0541                _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", item);
 0542            }
 0543        }
 544
 0545        return manifests.ToArray();
 0546    }
 547
 548    private static async ValueTask<BackupManifest?> GetManifest(string archivePath)
 549    {
 0550        var archiveStream = File.OpenRead(archivePath);
 0551        await using (archiveStream.ConfigureAwait(false))
 552        {
 0553            using var zipStream = new ZipArchive(archiveStream, ZipArchiveMode.Read);
 0554            var manifestEntry = zipStream.GetEntry(ManifestEntryName);
 0555            if (manifestEntry is null)
 556            {
 0557                return null;
 558            }
 559
 0560            var manifestStream = await manifestEntry.OpenAsync().ConfigureAwait(false);
 0561            await using (manifestStream.ConfigureAwait(false))
 562            {
 0563                return await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).Config
 564            }
 0565        }
 0566    }
 567
 568    private static BackupManifestDto Map(BackupManifest manifest, string path)
 569    {
 1570        return new BackupManifestDto()
 1571        {
 1572            BackupEngineVersion = manifest.BackupEngineVersion,
 1573            DateCreated = manifest.DateCreated,
 1574            ServerVersion = manifest.ServerVersion,
 1575            Path = path,
 1576            Options = Map(manifest.Options)
 1577        };
 578    }
 579
 580    private static BackupOptionsDto Map(BackupOptions options)
 581    {
 1582        return new BackupOptionsDto()
 1583        {
 1584            Metadata = options.Metadata,
 1585            Subtitles = options.Subtitles,
 1586            Trickplay = options.Trickplay,
 1587            Database = options.Database
 1588        };
 589    }
 590
 591    private static BackupOptions Map(BackupOptionsDto options)
 592    {
 1593        return new BackupOptions()
 1594        {
 1595            Metadata = options.Metadata,
 1596            Subtitles = options.Subtitles,
 1597            Trickplay = options.Trickplay,
 1598            Database = options.Database
 1599        };
 600    }
 601
 602    /// <summary>
 603    /// Windows is able to handle '/' as a path seperator in zip files
 604    /// but linux isn't able to handle '\' as a path seperator in zip files,
 605    /// So normalize to '/'.
 606    /// </summary>
 607    /// <param name="path">The path to normalize.</param>
 608    /// <returns>The normalized path. </returns>
 609    private static string NormalizePathSeparator(string path)
 31610        => path.Replace('\\', '/');
 611}