aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormartimarkov <martimarkov@users.noreply.github.com>2026-10-05 19:18:58 -0400
committerCody Robibero <cody@robibe.ro>2026-10-05 19:18:58 -0400
commit0ca7042ed7220a792651032b8dcee3e16042cbbb (patch)
tree2211801243666e1bdd5b4fb56b54567a61296b3a
parent8a93a627c4ad9bba99f607bed6a3e40e619481fc (diff)
Backport pull request #18264 from jellyfin/release-12.z
Refuse to start a set-up server whose database is missing or empty Original-merge: d3e0e9abe4a07f3bfc86ff520a17b605ac8ce39a Merged-by: crobibero <cody@robibe.ro> Backported-by: Cody Robibero <cody@robibe.ro>
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationService.cs56
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs20
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/StartOverTests.cs119
-rw-r--r--tests/Jellyfin.Server.Tests/Migrations/JellyfinMigrationServiceTests.cs187
4 files changed, 381 insertions, 1 deletions
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
index a5b6bc4604..21ffab8dd6 100644
--- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
@@ -132,6 +132,14 @@ internal class JellyfinMigrationService
}
else
{
+ // Only a media server start is refused. A MigrateSystem run is left to the migrations, which another database
+ // provider may rely on to initialise its database, and a restore replaces the database later, in StartServer.
+ var startupMode = startupOptions.StartupMode ?? Configuration.StartupMode.MediaServer;
+ if (startupMode == Configuration.StartupMode.MediaServer && string.IsNullOrWhiteSpace(startupOptions.RestoreArchive))
+ {
+ await EnsureExistingDatabaseAsync(appPaths, logger).ConfigureAwait(false);
+ }
+
// migrate any existing migration.xml files
var migrationConfigPath = Path.Join(appPaths.ConfigurationDirectoryPath, "migrations.xml");
var migrationOptions = File.Exists(migrationConfigPath)
@@ -184,6 +192,54 @@ internal class JellyfinMigrationService
}
/// <summary>
+ /// Stops the startup of a server that has been set up before but whose database is missing or empty. Running the migrations
+ /// against such a database fails part way through, and seeding it would leave a server nobody can log in to.
+ /// </summary>
+ private async Task EnsureExistingDatabaseAsync(IApplicationPaths appPaths, ILogger logger)
+ {
+ string? problem = null;
+ var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var databaseCreator = dbContext.Database.GetService<IDatabaseCreator>() as IRelationalDatabaseCreator
+ ?? throw new InvalidOperationException("Jellyfin does only support relational databases.");
+
+ // Check existence first: opening a connection to a missing SQLite database creates an empty file.
+ if (!await databaseCreator.ExistsAsync().ConfigureAwait(false))
+ {
+ problem = "the database does not exist";
+ }
+ else
+ {
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+ if (!await historyRepository.ExistsAsync().ConfigureAwait(false))
+ {
+ problem = "the database has no migration history";
+ }
+ else if ((await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false)).Count == 0)
+ {
+ problem = "the migration history of the database is empty";
+ }
+ }
+ }
+
+ if (problem is null)
+ {
+ return;
+ }
+
+ var message = string.Format(
+ CultureInfo.InvariantCulture,
+ "This server has been set up before (IsStartupWizardCompleted is true in {0}), but {1}. Jellyfin will not start an existing server with an empty database. "
+ + "To continue, either restore the previous database; or start over and keep this server's settings by setting IsStartupWizardCompleted to false in {0} "
+ + "(users, watch history and everything else stored in the database will not come back); or set up a new server with empty configuration and data directories.",
+ appPaths.SystemConfigurationFilePath,
+ problem);
+ logger.LogCritical("{Message}", message);
+ throw new InvalidOperationException(message);
+ }
+
+ /// <summary>
/// Runs all pending migrations of the requested stage.
/// </summary>
/// <param name="stage">The stage to migrate.</param>
diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
index 54f443de2d..bcd6cd9bf4 100644
--- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
@@ -29,6 +29,7 @@ namespace Jellyfin.Server.Integration.Tests
{
private static readonly string _testPathRoot = Path.Combine(Path.GetTempPath(), "jellyfin-test-data");
private readonly ConcurrentBag<IDisposable> _disposableComponents = new ConcurrentBag<IDisposable>();
+ private readonly string _webHostPathRoot;
/// <summary>
/// Initializes static members of the <see cref="JellyfinApplicationFactory"/> class.
@@ -42,6 +43,23 @@ namespace Jellyfin.Server.Integration.Tests
StartupHelpers.PerformStaticInitialization();
}
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JellyfinApplicationFactory"/> class using a new temporary directory.
+ /// </summary>
+ public JellyfinApplicationFactory()
+ : this(Path.Combine(_testPathRoot, "test-host-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName())))
+ {
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JellyfinApplicationFactory"/> class.
+ /// </summary>
+ /// <param name="webHostPathRoot">The directory the application paths are created in. Reusing it starts the same server again.</param>
+ protected JellyfinApplicationFactory(string webHostPathRoot)
+ {
+ _webHostPathRoot = webHostPathRoot;
+ }
+
/// <inheritdoc/>
protected override IHostBuilder CreateHostBuilder()
{
@@ -57,7 +75,7 @@ namespace Jellyfin.Server.Integration.Tests
var commandLineOpts = new StartupOptions();
// Use a temporary directory for the application paths
- var webHostPathRoot = Path.Combine(_testPathRoot, "test-host-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
+ var webHostPathRoot = _webHostPathRoot;
Directory.CreateDirectory(Path.Combine(webHostPathRoot, "logs"));
Directory.CreateDirectory(Path.Combine(webHostPathRoot, "config"));
Directory.CreateDirectory(Path.Combine(webHostPathRoot, "cache"));
diff --git a/tests/Jellyfin.Server.Integration.Tests/StartOverTests.cs b/tests/Jellyfin.Server.Integration.Tests/StartOverTests.cs
new file mode 100644
index 0000000000..b8c149891f
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/StartOverTests.cs
@@ -0,0 +1,119 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Serialization;
+using Jellyfin.Api.Models.StartupDtos;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Extensions.Json;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Jellyfin.Server.Integration.Tests;
+
+/// <summary>
+/// Covers starting a server that has been set up before after its database has been deleted.
+/// </summary>
+public sealed class StartOverTests : IDisposable
+{
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "jellyfin-test-data", "start-over-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
+ private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
+
+ [Fact]
+ public async Task DeletedDatabase_WizardReset_StartsAsNewServer()
+ {
+ var paths = await SetUpServerAndDeleteDatabaseAsync();
+ SetWizardCompleted(paths, false);
+
+ using var factory = new SameRootApplicationFactory(_root);
+ using var client = factory.CreateClient();
+
+ using var response = await client.GetAsync("/Startup/Configuration", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var dbContextFactory = factory.Services.GetRequiredService<IDbContextFactory<JellyfinDbContext>>();
+ await using var context = await dbContextFactory.CreateDbContextAsync(TestContext.Current.CancellationToken);
+ var applied = (await context.Database.GetAppliedMigrationsAsync(TestContext.Current.CancellationToken)).ToHashSet(StringComparer.Ordinal);
+ Assert.All(context.GetService<IMigrationsAssembly>().Migrations.Keys, id => Assert.Contains(id, applied));
+ }
+
+ [Fact]
+ public async Task DeletedDatabase_WizardStillCompleted_RefusesToStart()
+ {
+ var paths = await SetUpServerAndDeleteDatabaseAsync();
+ var systemConfiguration = await File.ReadAllBytesAsync(paths.SystemConfigurationFilePath, TestContext.Current.CancellationToken);
+
+ using var factory = new SameRootApplicationFactory(_root);
+ var exception = Assert.ThrowsAny<Exception>(() => factory.CreateClient());
+
+ var guard = exception as InvalidOperationException ?? exception.InnerException as InvalidOperationException;
+ Assert.NotNull(guard);
+ Assert.Contains("the database does not exist", guard.Message, StringComparison.Ordinal);
+ Assert.Empty(Directory.GetFiles(paths.DataPath, "jellyfin.db*"));
+ Assert.Equal(systemConfiguration, await File.ReadAllBytesAsync(paths.SystemConfigurationFilePath, TestContext.Current.CancellationToken));
+ }
+
+ public void Dispose()
+ {
+ SqliteConnection.ClearAllPools();
+ try
+ {
+ Directory.Delete(_root, true);
+ }
+ catch (IOException)
+ {
+ // Best effort, a locked file must not fail the test.
+ }
+ }
+
+ private static void SetWizardCompleted(IApplicationPaths paths, bool completed)
+ {
+ var serializer = new MyXmlSerializer();
+ var configuration = (ServerConfiguration)serializer.DeserializeFromFile(typeof(ServerConfiguration), paths.SystemConfigurationFilePath)!;
+ configuration.IsStartupWizardCompleted = completed;
+ serializer.SerializeToFile(configuration, paths.SystemConfigurationFilePath);
+ }
+
+ private async Task<IApplicationPaths> SetUpServerAndDeleteDatabaseAsync()
+ {
+ IApplicationPaths paths;
+ using (var factory = new SameRootApplicationFactory(_root))
+ {
+ using var client = factory.CreateClient();
+ paths = factory.Services.GetRequiredService<IApplicationPaths>();
+
+ using (var response = await client.GetAsync("/Startup/User", TestContext.Current.CancellationToken))
+ {
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ var user = new StartupUserDto { Name = "StartOver", Password = "StartOver" };
+ using (var response = await client.PostAsJsonAsync("/Startup/User", user, _jsonOptions, TestContext.Current.CancellationToken))
+ {
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ }
+ }
+
+ // Completing the wizard through the API would queue a library scan that can still hold the database file when
+ // it is deleted below, so the flag is set on the stopped server instead.
+ SetWizardCompleted(paths, true);
+ SqliteConnection.ClearAllPools();
+ foreach (var file in Directory.GetFiles(paths.DataPath, "jellyfin.db*"))
+ {
+ File.Delete(file);
+ }
+
+ return paths;
+ }
+
+ private sealed class SameRootApplicationFactory(string webHostPathRoot) : JellyfinApplicationFactory(webHostPathRoot);
+}
diff --git a/tests/Jellyfin.Server.Tests/Migrations/JellyfinMigrationServiceTests.cs b/tests/Jellyfin.Server.Tests/Migrations/JellyfinMigrationServiceTests.cs
new file mode 100644
index 0000000000..1d789f24a4
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/Migrations/JellyfinMigrationServiceTests.cs
@@ -0,0 +1,187 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Emby.Server.Implementations;
+using Emby.Server.Implementations.Configuration;
+using Emby.Server.Implementations.Serialization;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.Implementations.DatabaseConfiguration;
+using Jellyfin.Server.Implementations.Extensions;
+using Jellyfin.Server.Migrations;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.Migrations;
+
+/// <summary>
+/// Covers how the migration service treats the database of a server that has been set up before.
+/// </summary>
+public sealed class JellyfinMigrationServiceTests : IDisposable
+{
+ private readonly string _root;
+ private readonly ServerApplicationPaths _paths;
+ private readonly List<ServiceProvider> _serviceProviders = [];
+
+ public JellyfinMigrationServiceTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "jellyfin-migration-service-tests", Guid.NewGuid().ToString("N"));
+ _paths = new ServerApplicationPaths(
+ Path.Combine(_root, "data"),
+ Path.Combine(_root, "log"),
+ Path.Combine(_root, "config"),
+ Path.Combine(_root, "cache"),
+ Path.Combine(_root, "web"));
+ Directory.CreateDirectory(_paths.DataPath);
+ Directory.CreateDirectory(_paths.LogDirectoryPath);
+ Directory.CreateDirectory(_paths.ConfigurationDirectoryPath);
+ Directory.CreateDirectory(_paths.CachePath);
+ }
+
+ private string DatabasePath => Path.Combine(_paths.DataPath, "jellyfin.db");
+
+ [Fact]
+ public async Task CheckFirstTimeRunOrMigration_SetUpServerWithoutDatabase_ThrowsWithoutCreatingIt()
+ {
+ WriteServerConfiguration(wizardCompleted: true);
+ var service = CreateService();
+ var systemConfiguration = await File.ReadAllBytesAsync(_paths.SystemConfigurationFilePath, TestContext.Current.CancellationToken);
+
+ var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckFirstTimeRunOrMigration(_paths, new StartupOptions()));
+
+ Assert.Contains("the database does not exist", exception.Message, StringComparison.Ordinal);
+ Assert.Contains(_paths.SystemConfigurationFilePath, exception.Message, StringComparison.Ordinal);
+ Assert.Empty(Directory.GetFiles(_paths.DataPath, "jellyfin.db*"));
+ Assert.Equal(systemConfiguration, await File.ReadAllBytesAsync(_paths.SystemConfigurationFilePath, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task CheckFirstTimeRunOrMigration_SetUpServerWithEmptyDatabaseFile_Throws()
+ {
+ WriteServerConfiguration(wizardCompleted: true);
+ await File.WriteAllBytesAsync(DatabasePath, [], TestContext.Current.CancellationToken);
+ var service = CreateService();
+
+ var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckFirstTimeRunOrMigration(_paths, new StartupOptions()));
+
+ Assert.Contains("the database has no migration history", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task CheckFirstTimeRunOrMigration_SetUpServerWithEmptyHistory_Throws()
+ {
+ WriteServerConfiguration(wizardCompleted: true);
+ var service = CreateService();
+ await using (var context = await CreateDbContextAsync())
+ {
+ await context.GetService<IHistoryRepository>().CreateIfNotExistsAsync(TestContext.Current.CancellationToken);
+ }
+
+ var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckFirstTimeRunOrMigration(_paths, new StartupOptions()));
+
+ Assert.Contains("the migration history of the database is empty", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task CheckFirstTimeRunOrMigration_SetUpServerWithHistory_Passes()
+ {
+ WriteServerConfiguration(wizardCompleted: false);
+ await CreateService().CheckFirstTimeRunOrMigration(_paths, new StartupOptions());
+ var applied = await GetAppliedMigrationIdsAsync();
+ WriteServerConfiguration(wizardCompleted: true);
+
+ await CreateService().CheckFirstTimeRunOrMigration(_paths, new StartupOptions());
+
+ Assert.NotEmpty(applied);
+ Assert.Equal(applied, await GetAppliedMigrationIdsAsync());
+ }
+
+ [Fact]
+ public async Task CheckFirstTimeRunOrMigration_SeedSystemOnSetUpServerWithoutDatabase_SeedsCodeMigrations()
+ {
+ WriteServerConfiguration(wizardCompleted: true);
+
+ await CreateService().CheckFirstTimeRunOrMigration(_paths, new StartupOptions { StartupMode = Configuration.StartupMode.SeedSystem });
+
+ Assert.NotEmpty(await GetAppliedMigrationIdsAsync());
+ }
+
+ [Fact]
+ public async Task CheckFirstTimeRunOrMigration_MigrateSystemOnSetUpServerWithoutDatabase_Passes()
+ {
+ WriteServerConfiguration(wizardCompleted: true);
+
+ await CreateService().CheckFirstTimeRunOrMigration(_paths, new StartupOptions { StartupMode = Configuration.StartupMode.MigrateSystem });
+ }
+
+ [Fact]
+ public async Task CheckFirstTimeRunOrMigration_RestoreArchiveOnSetUpServerWithoutDatabase_Passes()
+ {
+ WriteServerConfiguration(wizardCompleted: true);
+
+ await CreateService().CheckFirstTimeRunOrMigration(_paths, new StartupOptions { RestoreArchive = Path.Combine(_root, "backup.zip") });
+ }
+
+ public void Dispose()
+ {
+ foreach (var serviceProvider in _serviceProviders)
+ {
+ serviceProvider.Dispose();
+ }
+
+ SqliteConnection.ClearAllPools();
+ try
+ {
+ Directory.Delete(_root, true);
+ }
+ catch (IOException)
+ {
+ // Best effort, a locked file must not fail the test.
+ }
+ }
+
+ private void WriteServerConfiguration(bool wizardCompleted)
+ {
+ new MyXmlSerializer().SerializeToFile(new ServerConfiguration { IsStartupWizardCompleted = wizardCompleted }, _paths.SystemConfigurationFilePath);
+ }
+
+ private JellyfinMigrationService CreateService()
+ {
+ var configurationManager = new ServerConfigurationManager(_paths, NullLoggerFactory.Instance, new MyXmlSerializer());
+ configurationManager.AddParts([new DatabaseConfigurationFactory()]);
+ var serviceProvider = new ServiceCollection()
+ .AddLogging()
+ .AddJellyfinDbContext(configurationManager, new ConfigurationBuilder().Build())
+ .AddSingleton<IApplicationPaths>(_paths)
+ .RegisterStartupLogger()
+ .BuildServiceProvider();
+ _serviceProviders.Add(serviceProvider);
+
+ var factory = serviceProvider.GetRequiredService<IDbContextFactory<JellyfinDbContext>>();
+ serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>().DbContextFactory = factory;
+ return ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider);
+ }
+
+ private async Task<JellyfinDbContext> CreateDbContextAsync()
+ {
+ var factory = _serviceProviders[^1].GetRequiredService<IDbContextFactory<JellyfinDbContext>>();
+ return await factory.CreateDbContextAsync(TestContext.Current.CancellationToken);
+ }
+
+ private async Task<string[]> GetAppliedMigrationIdsAsync()
+ {
+ await using var context = await CreateDbContextAsync();
+ var applied = await context.Database.GetAppliedMigrationsAsync(TestContext.Current.CancellationToken);
+ return applied.Order(StringComparer.Ordinal).ToArray();
+ }
+}