using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.FullSystemBackup;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.SystemBackupService;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
namespace Jellyfin.Server.Implementations.Tests.FullSystemBackup;
///
/// Tests for , in particular that a single row of corrupt
/// (e.g. malformed KeyframeTicks JSON) does not abort
/// an otherwise healthy backup. See https://github.com/jellyfin/jellyfin/issues/17216.
///
public sealed class BackupServiceTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions _dbOptions;
private readonly string _testRoot;
private readonly string _backupPath;
private readonly string _configurationDirectoryPath;
public BackupServiceTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder()
.UseSqlite(_connection)
.Options;
using (var ctx = CreateDbContext())
{
ctx.Database.EnsureCreated();
}
// Use the test assembly's own output directory instead of Path.GetTempPath(). On GitHub-hosted
// windows-latest runners, the system temp directory lives on the constrained C: drive, which can have
// less than the 5GiB BackupService requires free, causing spurious failures. AppContext.BaseDirectory
// is under the repo checkout (the much larger D: drive on Windows runners) on all platforms.
_testRoot = Path.Combine(AppContext.BaseDirectory, "jellyfin-backup-service-tests-" + Guid.NewGuid().ToString("N"));
_backupPath = Path.Combine(_testRoot, "Backup");
_configurationDirectoryPath = Path.Combine(_testRoot, "Config");
Directory.CreateDirectory(_backupPath);
Directory.CreateDirectory(_configurationDirectoryPath);
}
public void Dispose()
{
_connection.Dispose();
if (Directory.Exists(_testRoot))
{
Directory.Delete(_testRoot, true);
}
}
[Fact]
public async Task CreateBackupAsync_WithCorruptKeyframeDataRow_SkipsRowAndCompletesBackup()
{
var cancellationToken = TestContext.Current.CancellationToken;
var validItemId = Guid.Parse("11111111-1111-1111-1111-111111111111");
var corruptItemId = Guid.Parse("22222222-2222-2222-2222-222222222222");
await using (var ctx = CreateDbContext())
{
// A healthy item + keyframe row, written the normal way.
ctx.BaseItems.Add(CreateMovieEntity(validItemId, "Good Movie"));
ctx.BaseItems.Add(CreateMovieEntity(corruptItemId, "Corrupt Movie"));
await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true);
ctx.KeyframeData.Add(new KeyframeData
{
ItemId = validItemId,
TotalDuration = 60_000,
KeyframeTicks = [0, 1000, 2000]
});
await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true);
// Simulate a corrupted database row: truncated JSON array for KeyframeTicks,
// written directly via SQL to bypass EF's normal (well-formed) write path.
await ctx.Database.ExecuteSqlInterpolatedAsync(
$"INSERT INTO KeyframeData (ItemId, TotalDuration, KeyframeTicks) VALUES ({corruptItemId.ToString()}, {5000L}, {"[1,2,3"})",
cancellationToken).ConfigureAwait(true);
}
var backupService = CreateBackupService();
var manifest = await backupService.CreateBackupAsync(new BackupOptionsDto()).ConfigureAwait(true);
Assert.True(File.Exists(manifest.Path));
using var archive = await ZipFile.OpenReadAsync(manifest.Path, cancellationToken).ConfigureAwait(true);
var keyframeEntry = archive.GetEntry("Database/KeyframeData.json");
Assert.NotNull(keyframeEntry);
await using var entryStream = await keyframeEntry!.OpenAsync(cancellationToken).ConfigureAwait(true);
using var document = await JsonDocument.ParseAsync(entryStream, cancellationToken: cancellationToken).ConfigureAwait(true);
var rows = document.RootElement.EnumerateArray().ToList();
// The corrupt row must be skipped, but the valid row must still make it into the backup.
var singleRow = Assert.Single(rows);
Assert.Equal(validItemId, singleRow.GetProperty("ItemId").GetGuid());
}
private BackupService CreateBackupService()
{
var factory = new Mock>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
factory.Setup(f => f.CreateDbContextAsync(It.IsAny())).ReturnsAsync(CreateDbContext);
var applicationHost = new Mock();
applicationHost.Setup(a => a.ApplicationVersion).Returns(new Version(10, 11, 0));
var applicationPaths = new Mock();
applicationPaths.Setup(a => a.BackupPath).Returns(_backupPath);
applicationPaths.Setup(a => a.ConfigurationDirectoryPath).Returns(_configurationDirectoryPath);
applicationPaths.Setup(a => a.DataPath).Returns(Path.Combine(_testRoot, "Data"));
applicationPaths.Setup(a => a.RootFolderPath).Returns(Path.Combine(_testRoot, "Root"));
applicationPaths.Setup(a => a.InternalMetadataPath).Returns(Path.Combine(_testRoot, "Metadata"));
applicationPaths.Setup(a => a.DefaultInternalMetadataPath).Returns(Path.Combine(_testRoot, "MetadataDefault"));
var jellyfinDatabaseProvider = new Mock();
jellyfinDatabaseProvider.Setup(p => p.RunScheduledOptimisation(It.IsAny())).Returns(Task.CompletedTask);
jellyfinDatabaseProvider.Setup(p => p.PurgeDatabase(It.IsAny(), It.IsAny>())).Returns(Task.CompletedTask);
var applicationLifetime = new Mock();
var libraryManager = new Mock();
libraryManager.Setup(l => l.IsScanRunning).Returns(false);
return new BackupService(
NullLogger.Instance,
factory.Object,
applicationHost.Object,
applicationPaths.Object,
jellyfinDatabaseProvider.Object,
applicationLifetime.Object,
libraryManager.Object);
}
private static BaseItemEntity CreateMovieEntity(Guid id, string name)
{
return new BaseItemEntity
{
Id = id,
Type = "Movie",
Name = name,
PresentationUniqueKey = id.ToString("N"),
MediaType = "Video",
IsMovie = true,
IsFolder = false,
IsVirtualItem = false
};
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger.Instance,
new SqliteDatabaseProvider(null!, NullLogger.Instance),
new NoLockBehavior(NullLogger.Instance));
}
}