فهرست منبع

Feature/version check in library migration (#14105)

JPVenson 2 روز پیش
والد
کامیت
88332e89c4

+ 1 - 0
Directory.Packages.props

@@ -51,6 +51,7 @@
     <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.5" />
     <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.5" />
     <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
     <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
     <PackageVersion Include="MimeTypes" Version="2.5.2" />
     <PackageVersion Include="MimeTypes" Version="2.5.2" />
+    <PackageVersion Include="Morestachio" Version="5.0.1.631" />
     <PackageVersion Include="Moq" Version="4.18.4" />
     <PackageVersion Include="Moq" Version="4.18.4" />
     <PackageVersion Include="NEbml" Version="0.12.0" />
     <PackageVersion Include="NEbml" Version="0.12.0" />
     <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
     <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />

+ 5 - 1
Jellyfin.Server/Helpers/StartupHelpers.cs

@@ -3,18 +3,19 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.Globalization;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
-using System.Net;
 using System.Runtime.InteropServices;
 using System.Runtime.InteropServices;
 using System.Runtime.Versioning;
 using System.Runtime.Versioning;
 using System.Text;
 using System.Text;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Emby.Server.Implementations;
 using Emby.Server.Implementations;
+using Jellyfin.Server.ServerSetupApp;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Extensions;
 using MediaBrowser.Controller.Extensions;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.IO;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 using Serilog;
 using Serilog;
+using Serilog.Extensions.Logging;
 using ILogger = Microsoft.Extensions.Logging.ILogger;
 using ILogger = Microsoft.Extensions.Logging.ILogger;
 
 
 namespace Jellyfin.Server.Helpers;
 namespace Jellyfin.Server.Helpers;
@@ -257,11 +258,14 @@ public static class StartupHelpers
     {
     {
         try
         try
         {
         {
+            var startupLogger = new LoggerProviderCollection();
+            startupLogger.AddProvider(new SetupServer.SetupLoggerFactory());
             // Serilog.Log is used by SerilogLoggerFactory when no logger is specified
             // Serilog.Log is used by SerilogLoggerFactory when no logger is specified
             Log.Logger = new LoggerConfiguration()
             Log.Logger = new LoggerConfiguration()
                 .ReadFrom.Configuration(configuration)
                 .ReadFrom.Configuration(configuration)
                 .Enrich.FromLogContext()
                 .Enrich.FromLogContext()
                 .Enrich.WithThreadId()
                 .Enrich.WithThreadId()
+                .WriteTo.Async(e => e.Providers(startupLogger))
                 .CreateLogger();
                 .CreateLogger();
         }
         }
         catch (Exception ex)
         catch (Exception ex)

+ 4 - 0
Jellyfin.Server/Jellyfin.Server.csproj

@@ -48,6 +48,7 @@
     <PackageReference Include="Microsoft.Extensions.Configuration.Json" />
     <PackageReference Include="Microsoft.Extensions.Configuration.Json" />
     <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
     <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
     <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
     <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
+    <PackageReference Include="Morestachio" />
     <PackageReference Include="prometheus-net" />
     <PackageReference Include="prometheus-net" />
     <PackageReference Include="prometheus-net.AspNetCore" />
     <PackageReference Include="prometheus-net.AspNetCore" />
     <PackageReference Include="Serilog.AspNetCore" />
     <PackageReference Include="Serilog.AspNetCore" />
@@ -79,6 +80,9 @@
     <None Update="wwwroot\api-docs\banner-dark.svg">
     <None Update="wwwroot\api-docs\banner-dark.svg">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </None>
     </None>
+    <None Update="ServerSetupApp/index.mstemplate.html">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
   </ItemGroup>
 
 
 </Project>
 </Project>

+ 23 - 18
Jellyfin.Server/Migrations/JellyfinMigrationService.cs

@@ -10,13 +10,13 @@ using Emby.Server.Implementations.Serialization;
 using Jellyfin.Database.Implementations;
 using Jellyfin.Database.Implementations;
 using Jellyfin.Server.Implementations.SystemBackupService;
 using Jellyfin.Server.Implementations.SystemBackupService;
 using Jellyfin.Server.Migrations.Stages;
 using Jellyfin.Server.Migrations.Stages;
+using Jellyfin.Server.ServerSetupApp;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.SystemBackupService;
 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;
 using Microsoft.EntityFrameworkCore.Migrations;
 using Microsoft.EntityFrameworkCore.Migrations;
-using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 
 
 namespace Jellyfin.Server.Migrations;
 namespace Jellyfin.Server.Migrations;
@@ -29,6 +29,7 @@ internal class JellyfinMigrationService
     private const string DbFilename = "library.db";
     private const string DbFilename = "library.db";
     private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
     private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
     private readonly ILoggerFactory _loggerFactory;
     private readonly ILoggerFactory _loggerFactory;
+    private readonly IStartupLogger _startupLogger;
     private readonly IBackupService? _backupService;
     private readonly IBackupService? _backupService;
     private readonly IJellyfinDatabaseProvider? _jellyfinDatabaseProvider;
     private readonly IJellyfinDatabaseProvider? _jellyfinDatabaseProvider;
     private readonly IApplicationPaths _applicationPaths;
     private readonly IApplicationPaths _applicationPaths;
@@ -39,18 +40,21 @@ internal class JellyfinMigrationService
     /// </summary>
     /// </summary>
     /// <param name="dbContextFactory">Provides access to the jellyfin database.</param>
     /// <param name="dbContextFactory">Provides access to the jellyfin database.</param>
     /// <param name="loggerFactory">The logger factory.</param>
     /// <param name="loggerFactory">The logger factory.</param>
+    /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
     /// <param name="applicationPaths">Application paths for library.db backup.</param>
     /// <param name="applicationPaths">Application paths for library.db backup.</param>
     /// <param name="backupService">The jellyfin backup service.</param>
     /// <param name="backupService">The jellyfin backup service.</param>
     /// <param name="jellyfinDatabaseProvider">The jellyfin database provider.</param>
     /// <param name="jellyfinDatabaseProvider">The jellyfin database provider.</param>
     public JellyfinMigrationService(
     public JellyfinMigrationService(
         IDbContextFactory<JellyfinDbContext> dbContextFactory,
         IDbContextFactory<JellyfinDbContext> dbContextFactory,
         ILoggerFactory loggerFactory,
         ILoggerFactory loggerFactory,
+        IStartupLogger startupLogger,
         IApplicationPaths applicationPaths,
         IApplicationPaths applicationPaths,
         IBackupService? backupService = null,
         IBackupService? backupService = null,
         IJellyfinDatabaseProvider? jellyfinDatabaseProvider = null)
         IJellyfinDatabaseProvider? jellyfinDatabaseProvider = null)
     {
     {
         _dbContextFactory = dbContextFactory;
         _dbContextFactory = dbContextFactory;
         _loggerFactory = loggerFactory;
         _loggerFactory = loggerFactory;
+        _startupLogger = startupLogger;
         _backupService = backupService;
         _backupService = backupService;
         _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
         _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
         _applicationPaths = applicationPaths;
         _applicationPaths = applicationPaths;
@@ -80,14 +84,14 @@ internal class JellyfinMigrationService
 
 
     private interface IInternalMigration
     private interface IInternalMigration
     {
     {
-        Task PerformAsync(ILogger logger);
+        Task PerformAsync(IStartupLogger logger);
     }
     }
 
 
     private HashSet<MigrationStage> Migrations { get; set; }
     private HashSet<MigrationStage> Migrations { get; set; }
 
 
     public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
     public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
     {
     {
-        var logger = _loggerFactory.CreateLogger<JellyfinMigrationService>();
+        var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup");
         logger.LogInformation("Initialise Migration service.");
         logger.LogInformation("Initialise Migration service.");
         var xmlSerializer = new MyXmlSerializer();
         var xmlSerializer = new MyXmlSerializer();
         var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath)
         var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath)
@@ -173,8 +177,7 @@ internal class JellyfinMigrationService
 
 
     public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider)
     public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider)
     {
     {
-        var logger = _loggerFactory.CreateLogger<JellyfinMigrationService>();
-        logger.LogInformation("Migrate stage {Stage}.", stage);
+        var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}.");
         ICollection<CodeMigration> migrationStage = (Migrations.FirstOrDefault(e => e.Stage == stage) as ICollection<CodeMigration>) ?? [];
         ICollection<CodeMigration> migrationStage = (Migrations.FirstOrDefault(e => e.Stage == stage) as ICollection<CodeMigration>) ?? [];
 
 
         var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
         var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
@@ -202,21 +205,23 @@ internal class JellyfinMigrationService
 
 
             foreach (var item in migrations)
             foreach (var item in migrations)
             {
             {
+                var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
                 try
                 try
                 {
                 {
-                    logger.LogInformation("Perform migration {Name}", item.Key);
-                    await item.Migration.PerformAsync(_loggerFactory.CreateLogger(item.GetType().Name)).ConfigureAwait(false);
-                    logger.LogInformation("Migration {Name} was successfully applied", item.Key);
+                    migrationLogger.LogInformation("Perform migration {Name}", item.Key);
+                    await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
+                    migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
                 }
                 }
                 catch (Exception ex)
                 catch (Exception ex)
                 {
                 {
-                    logger.LogCritical(ex, "Migration {Name} failed, migration service will attempt to roll back.", item.Key);
+                    migrationLogger.LogCritical("Error: {Error}", ex.Message);
+                    migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
 
 
                     if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
                     if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
                     {
                     {
                         if (_backupKey.LibraryDb is not null)
                         if (_backupKey.LibraryDb is not null)
                         {
                         {
-                            logger.LogInformation("Attempt to rollback librarydb.");
+                            migrationLogger.LogInformation("Attempt to rollback librarydb.");
                             try
                             try
                             {
                             {
                                 var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
                                 var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
@@ -224,33 +229,33 @@ internal class JellyfinMigrationService
                             }
                             }
                             catch (Exception inner)
                             catch (Exception inner)
                             {
                             {
-                                logger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
+                                migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
                             }
                             }
                         }
                         }
 
 
                         if (_backupKey.JellyfinDb is not null)
                         if (_backupKey.JellyfinDb is not null)
                         {
                         {
-                            logger.LogInformation("Attempt to rollback JellyfinDb.");
+                            migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
                             try
                             try
                             {
                             {
                                 await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
                                 await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
                             }
                             }
                             catch (Exception inner)
                             catch (Exception inner)
                             {
                             {
-                                logger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
+                                migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
                             }
                             }
                         }
                         }
 
 
                         if (_backupKey.FullBackup is not null)
                         if (_backupKey.FullBackup is not null)
                         {
                         {
-                            logger.LogInformation("Attempt to rollback from backup.");
+                            migrationLogger.LogInformation("Attempt to rollback from backup.");
                             try
                             try
                             {
                             {
                                 await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
                                 await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
                             }
                             }
                             catch (Exception inner)
                             catch (Exception inner)
                             {
                             {
-                                logger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
+                                migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
                             }
                             }
                         }
                         }
                     }
                     }
@@ -416,9 +421,9 @@ internal class JellyfinMigrationService
             _dbContext = dbContext;
             _dbContext = dbContext;
         }
         }
 
 
-        public async Task PerformAsync(ILogger logger)
+        public async Task PerformAsync(IStartupLogger logger)
         {
         {
-            await _codeMigration.Perform(_serviceProvider, CancellationToken.None).ConfigureAwait(false);
+            await _codeMigration.Perform(_serviceProvider, logger, CancellationToken.None).ConfigureAwait(false);
 
 
             var historyRepository = _dbContext.GetService<IHistoryRepository>();
             var historyRepository = _dbContext.GetService<IHistoryRepository>();
             var createScript = historyRepository.GetInsertScript(new HistoryRow(_codeMigration.BuildCodeMigrationId(), GetJellyfinVersion()));
             var createScript = historyRepository.GetInsertScript(new HistoryRow(_codeMigration.BuildCodeMigrationId(), GetJellyfinVersion()));
@@ -437,7 +442,7 @@ internal class JellyfinMigrationService
             _jellyfinDbContext = jellyfinDbContext;
             _jellyfinDbContext = jellyfinDbContext;
         }
         }
 
 
-        public async Task PerformAsync(ILogger logger)
+        public async Task PerformAsync(IStartupLogger logger)
         {
         {
             var migrator = _jellyfinDbContext.GetService<IMigrator>();
             var migrator = _jellyfinDbContext.GetService<IMigrator>();
             await migrator.MigrateAsync(_databaseMigrationInfo.Key).ConfigureAwait(false);
             await migrator.MigrateAsync(_databaseMigrationInfo.Key).ConfigureAwait(false);

+ 5 - 4
Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs

@@ -9,6 +9,7 @@ using Jellyfin.Data.Enums;
 using Jellyfin.Database.Implementations;
 using Jellyfin.Database.Implementations;
 using Jellyfin.Database.Implementations.Entities;
 using Jellyfin.Database.Implementations.Entities;
 using Jellyfin.Extensions.Json;
 using Jellyfin.Extensions.Json;
+using Jellyfin.Server.ServerSetupApp;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.EntityFrameworkCore;
@@ -22,7 +23,7 @@ namespace Jellyfin.Server.Migrations.Routines;
 [JellyfinMigration("2025-04-21T00:00:00", nameof(MigrateKeyframeData))]
 [JellyfinMigration("2025-04-21T00:00:00", nameof(MigrateKeyframeData))]
 public class MigrateKeyframeData : IDatabaseMigrationRoutine
 public class MigrateKeyframeData : IDatabaseMigrationRoutine
 {
 {
-    private readonly ILogger<MigrateKeyframeData> _logger;
+    private readonly IStartupLogger _logger;
     private readonly IApplicationPaths _appPaths;
     private readonly IApplicationPaths _appPaths;
     private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
     private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
     private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
     private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
@@ -30,15 +31,15 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
     /// <summary>
     /// <summary>
     /// Initializes a new instance of the <see cref="MigrateKeyframeData"/> class.
     /// Initializes a new instance of the <see cref="MigrateKeyframeData"/> class.
     /// </summary>
     /// </summary>
-    /// <param name="logger">The logger.</param>
+    /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
     /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
     /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
     /// <param name="dbProvider">The EFCore db factory.</param>
     /// <param name="dbProvider">The EFCore db factory.</param>
     public MigrateKeyframeData(
     public MigrateKeyframeData(
-        ILogger<MigrateKeyframeData> logger,
+        IStartupLogger startupLogger,
         IApplicationPaths appPaths,
         IApplicationPaths appPaths,
         IDbContextFactory<JellyfinDbContext> dbProvider)
         IDbContextFactory<JellyfinDbContext> dbProvider)
     {
     {
-        _logger = logger;
+        _logger = startupLogger;
         _appPaths = appPaths;
         _appPaths = appPaths;
         _dbProvider = dbProvider;
         _dbProvider = dbProvider;
     }
     }

+ 6 - 7
Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs

@@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations;
 using Jellyfin.Database.Implementations.Entities;
 using Jellyfin.Database.Implementations.Entities;
 using Jellyfin.Extensions;
 using Jellyfin.Extensions;
 using Jellyfin.Server.Implementations.Item;
 using Jellyfin.Server.Implementations.Item;
+using Jellyfin.Server.ServerSetupApp;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
@@ -34,7 +35,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
 {
 {
     private const string DbFilename = "library.db";
     private const string DbFilename = "library.db";
 
 
-    private readonly ILogger<MigrateLibraryDb> _logger;
+    private readonly IStartupLogger _logger;
     private readonly IServerApplicationPaths _paths;
     private readonly IServerApplicationPaths _paths;
     private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
     private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
     private readonly IDbContextFactory<JellyfinDbContext> _provider;
     private readonly IDbContextFactory<JellyfinDbContext> _provider;
@@ -42,19 +43,17 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
     /// <summary>
     /// <summary>
     /// Initializes a new instance of the <see cref="MigrateLibraryDb"/> class.
     /// Initializes a new instance of the <see cref="MigrateLibraryDb"/> class.
     /// </summary>
     /// </summary>
-    /// <param name="logger">The logger.</param>
+    /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
     /// <param name="provider">The database provider.</param>
     /// <param name="provider">The database provider.</param>
     /// <param name="paths">The server application paths.</param>
     /// <param name="paths">The server application paths.</param>
     /// <param name="jellyfinDatabaseProvider">The database provider for special access.</param>
     /// <param name="jellyfinDatabaseProvider">The database provider for special access.</param>
-    /// <param name="serviceProvider">The Service provider.</param>
     public MigrateLibraryDb(
     public MigrateLibraryDb(
-        ILogger<MigrateLibraryDb> logger,
+        IStartupLogger startupLogger,
         IDbContextFactory<JellyfinDbContext> provider,
         IDbContextFactory<JellyfinDbContext> provider,
         IServerApplicationPaths paths,
         IServerApplicationPaths paths,
-        IJellyfinDatabaseProvider jellyfinDatabaseProvider,
-        IServiceProvider serviceProvider)
+        IJellyfinDatabaseProvider jellyfinDatabaseProvider)
     {
     {
-        _logger = logger;
+        _logger = startupLogger;
         _provider = provider;
         _provider = provider;
         _paths = paths;
         _paths = paths;
         _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
         _jellyfinDatabaseProvider = jellyfinDatabaseProvider;

+ 73 - 0
Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs

@@ -0,0 +1,73 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// The migration routine for checking if the current instance of Jellyfin is compatiable to be upgraded.
+/// </summary>
+[JellyfinMigration("2025-04-20T19:30:00", nameof(MigrateLibraryDbCompatibilityCheck))]
+public class MigrateLibraryDbCompatibilityCheck : IAsyncMigrationRoutine
+{
+    private const string DbFilename = "library.db";
+    private readonly IStartupLogger _logger;
+    private readonly IServerApplicationPaths _paths;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="MigrateLibraryDbCompatibilityCheck"/> class.
+    /// </summary>
+    /// <param name="startupLogger">The startup logger.</param>
+    /// <param name="paths">The Path service.</param>
+    public MigrateLibraryDbCompatibilityCheck(IStartupLogger startupLogger, IServerApplicationPaths paths)
+    {
+        _logger = startupLogger;
+        _paths = paths;
+    }
+
+    /// <inheritdoc/>
+    public async Task PerformAsync(CancellationToken cancellationToken)
+    {
+        var dataPath = _paths.DataPath;
+        var libraryDbPath = Path.Combine(dataPath, DbFilename);
+        if (!File.Exists(libraryDbPath))
+        {
+            _logger.LogError("Cannot migrate {LibraryDb} as it does not exist..", libraryDbPath);
+            return;
+        }
+
+        using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
+        await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+        CheckMigratableVersion(connection);
+        await connection.CloseAsync().ConfigureAwait(false);
+    }
+
+    private static void CheckMigratableVersion(SqliteConnection connection)
+    {
+        CheckColumnExistance(connection, "TypedBaseItems", "lufs");
+        CheckColumnExistance(connection, "TypedBaseItems", "normalizationgain");
+        CheckColumnExistance(connection, "mediastreams", "dvversionmajor");
+
+        static void CheckColumnExistance(SqliteConnection connection, string table, string column)
+        {
+            using (var cmd = connection.CreateCommand())
+            {
+#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
+                cmd.CommandText = $"Select COUNT(1) FROM pragma_table_xinfo('{table}') WHERE lower(name) = '{column}';";
+#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
+                var result = cmd.ExecuteScalar()!;
+                if (!result.Equals(1L))
+                {
+                    throw new InvalidOperationException("Your database does not meet the required standard. Only upgrades from server version 10.9.11 or above are supported. Please upgrade first to server version 10.10.7 before attempting to upgrade afterwards to 10.11");
+                }
+            }
+        }
+    }
+}

+ 4 - 3
Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs

@@ -1,6 +1,7 @@
 using System;
 using System;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Database.Implementations;
 using Jellyfin.Database.Implementations;
+using Jellyfin.Server.ServerSetupApp;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Globalization;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
@@ -16,18 +17,18 @@ namespace Jellyfin.Server.Migrations.Routines;
 #pragma warning restore CS0618 // Type or member is obsolete
 #pragma warning restore CS0618 // Type or member is obsolete
 internal class MigrateRatingLevels : IDatabaseMigrationRoutine
 internal class MigrateRatingLevels : IDatabaseMigrationRoutine
 {
 {
-    private readonly ILogger<MigrateRatingLevels> _logger;
+    private readonly IStartupLogger _logger;
     private readonly IDbContextFactory<JellyfinDbContext> _provider;
     private readonly IDbContextFactory<JellyfinDbContext> _provider;
     private readonly ILocalizationManager _localizationManager;
     private readonly ILocalizationManager _localizationManager;
 
 
     public MigrateRatingLevels(
     public MigrateRatingLevels(
         IDbContextFactory<JellyfinDbContext> provider,
         IDbContextFactory<JellyfinDbContext> provider,
-        ILoggerFactory loggerFactory,
+        IStartupLogger logger,
         ILocalizationManager localizationManager)
         ILocalizationManager localizationManager)
     {
     {
         _provider = provider;
         _provider = provider;
         _localizationManager = localizationManager;
         _localizationManager = localizationManager;
-        _logger = loggerFactory.CreateLogger<MigrateRatingLevels>();
+        _logger = logger;
     }
     }
 
 
     /// <inheritdoc/>
     /// <inheritdoc/>

+ 5 - 2
Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs

@@ -13,6 +13,7 @@ using System.Threading.Tasks;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
 using Jellyfin.Database.Implementations;
 using Jellyfin.Database.Implementations;
 using Jellyfin.Database.Implementations.Entities;
 using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.ServerSetupApp;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.IO;
 using MediaBrowser.Controller.IO;
@@ -29,7 +30,7 @@ namespace Jellyfin.Server.Migrations.Routines;
 public class MoveExtractedFiles : IAsyncMigrationRoutine
 public class MoveExtractedFiles : IAsyncMigrationRoutine
 {
 {
     private readonly IApplicationPaths _appPaths;
     private readonly IApplicationPaths _appPaths;
-    private readonly ILogger<MoveExtractedFiles> _logger;
+    private readonly ILogger _logger;
     private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
     private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
     private readonly IPathManager _pathManager;
     private readonly IPathManager _pathManager;
     private readonly IFileSystem _fileSystem;
     private readonly IFileSystem _fileSystem;
@@ -39,18 +40,20 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
     /// </summary>
     /// </summary>
     /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
     /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
     /// <param name="logger">The logger.</param>
     /// <param name="logger">The logger.</param>
+    /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
     /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
     /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
     /// <param name="pathManager">Instance of the <see cref="IPathManager"/> interface.</param>
     /// <param name="pathManager">Instance of the <see cref="IPathManager"/> interface.</param>
     /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
     /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
     public MoveExtractedFiles(
     public MoveExtractedFiles(
         IApplicationPaths appPaths,
         IApplicationPaths appPaths,
         ILogger<MoveExtractedFiles> logger,
         ILogger<MoveExtractedFiles> logger,
+        IStartupLogger startupLogger,
         IPathManager pathManager,
         IPathManager pathManager,
         IFileSystem fileSystem,
         IFileSystem fileSystem,
         IDbContextFactory<JellyfinDbContext> dbProvider)
         IDbContextFactory<JellyfinDbContext> dbProvider)
     {
     {
         _appPaths = appPaths;
         _appPaths = appPaths;
-        _logger = logger;
+        _logger = startupLogger.With(logger);
         _pathManager = pathManager;
         _pathManager = pathManager;
         _fileSystem = fileSystem;
         _fileSystem = fileSystem;
         _dbProvider = dbProvider;
         _dbProvider = dbProvider;

+ 3 - 2
Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs

@@ -4,6 +4,7 @@ using System.Globalization;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
+using Jellyfin.Server.ServerSetupApp;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Trickplay;
 using MediaBrowser.Controller.Trickplay;
@@ -23,7 +24,7 @@ public class MoveTrickplayFiles : IMigrationRoutine
     private readonly ITrickplayManager _trickplayManager;
     private readonly ITrickplayManager _trickplayManager;
     private readonly IFileSystem _fileSystem;
     private readonly IFileSystem _fileSystem;
     private readonly ILibraryManager _libraryManager;
     private readonly ILibraryManager _libraryManager;
-    private readonly ILogger<MoveTrickplayFiles> _logger;
+    private readonly IStartupLogger _logger;
 
 
     /// <summary>
     /// <summary>
     /// Initializes a new instance of the <see cref="MoveTrickplayFiles"/> class.
     /// Initializes a new instance of the <see cref="MoveTrickplayFiles"/> class.
@@ -36,7 +37,7 @@ public class MoveTrickplayFiles : IMigrationRoutine
         ITrickplayManager trickplayManager,
         ITrickplayManager trickplayManager,
         IFileSystem fileSystem,
         IFileSystem fileSystem,
         ILibraryManager libraryManager,
         ILibraryManager libraryManager,
-        ILogger<MoveTrickplayFiles> logger)
+        IStartupLogger logger)
     {
     {
         _trickplayManager = trickplayManager;
         _trickplayManager = trickplayManager;
         _fileSystem = fileSystem;
         _fileSystem = fileSystem;

+ 32 - 4
Jellyfin.Server/Migrations/Stages/CodeMigration.cs

@@ -2,7 +2,9 @@ using System;
 using System.Globalization;
 using System.Globalization;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
+using Jellyfin.Server.ServerSetupApp;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
 
 
 namespace Jellyfin.Server.Migrations.Stages;
 namespace Jellyfin.Server.Migrations.Stages;
 
 
@@ -16,10 +18,34 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta
 
 
     public string BuildCodeMigrationId()
     public string BuildCodeMigrationId()
     {
     {
-        return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + MigrationType.Name!;
+        return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!;
     }
     }
 
 
-    public async Task Perform(IServiceProvider? serviceProvider, CancellationToken cancellationToken)
+    private ServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger)
+    {
+        var childServiceCollection = new ServiceCollection();
+        childServiceCollection.AddSingleton(serviceProvider);
+        childServiceCollection.AddSingleton(logger);
+
+        foreach (ServiceDescriptor service in serviceProvider.GetRequiredService<IServiceCollection>())
+        {
+            if (service.Lifetime == ServiceLifetime.Singleton && !service.ServiceType.IsGenericTypeDefinition)
+            {
+                object? serviceInstance = serviceProvider.GetService(service.ServiceType);
+                if (serviceInstance != null)
+                {
+                    childServiceCollection.AddSingleton(service.ServiceType, serviceInstance);
+                    continue;
+                }
+            }
+
+            childServiceCollection.Add(service);
+        }
+
+        return childServiceCollection;
+    }
+
+    public async Task Perform(IServiceProvider? serviceProvider, IStartupLogger logger, CancellationToken cancellationToken)
     {
     {
 #pragma warning disable CS0618 // Type or member is obsolete
 #pragma warning disable CS0618 // Type or member is obsolete
         if (typeof(IMigrationRoutine).IsAssignableFrom(MigrationType))
         if (typeof(IMigrationRoutine).IsAssignableFrom(MigrationType))
@@ -30,7 +56,8 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta
             }
             }
             else
             else
             {
             {
-                ((IMigrationRoutine)ActivatorUtilities.CreateInstance(serviceProvider, MigrationType)).Perform();
+                using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider();
+                ((IMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).Perform();
 #pragma warning restore CS0618 // Type or member is obsolete
 #pragma warning restore CS0618 // Type or member is obsolete
             }
             }
         }
         }
@@ -42,7 +69,8 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta
             }
             }
             else
             else
             {
             {
-                await ((IAsyncMigrationRoutine)ActivatorUtilities.CreateInstance(serviceProvider, MigrationType)).PerformAsync(cancellationToken).ConfigureAwait(false);
+                using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider();
+                await ((IAsyncMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).PerformAsync(cancellationToken).ConfigureAwait(false);
             }
             }
         }
         }
         else
         else

+ 18 - 4
Jellyfin.Server/Program.cs

@@ -60,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 IStartupLogger? _migrationLogger;
         private static string? _restoreFromBackup;
         private static string? _restoreFromBackup;
 
 
         /// <summary>
         /// <summary>
@@ -98,9 +99,9 @@ namespace Jellyfin.Server
 
 
             // Create an instance of the application configuration to use for application startup
             // Create an instance of the application configuration to use for application startup
             IConfiguration startupConfig = CreateAppConfiguration(options, appPaths);
             IConfiguration startupConfig = CreateAppConfiguration(options, appPaths);
+            StartupHelpers.InitializeLoggingFramework(startupConfig, appPaths);
             _setupServer = new SetupServer(static () => _jellyfinHost?.Services?.GetService<INetworkManager>(), appPaths, static () => _appHost, _loggerFactory, startupConfig);
             _setupServer = new SetupServer(static () => _jellyfinHost?.Services?.GetService<INetworkManager>(), appPaths, static () => _appHost, _loggerFactory, startupConfig);
             await _setupServer.RunAsync().ConfigureAwait(false);
             await _setupServer.RunAsync().ConfigureAwait(false);
-            StartupHelpers.InitializeLoggingFramework(startupConfig, appPaths);
             _logger = _loggerFactory.CreateLogger("Main");
             _logger = _loggerFactory.CreateLogger("Main");
 
 
             // Use the logging framework for uncaught exceptions instead of std error
             // Use the logging framework for uncaught exceptions instead of std error
@@ -131,7 +132,7 @@ namespace Jellyfin.Server
                 }
                 }
             }
             }
 
 
-            StorageHelper.TestCommonPathsForStorageCapacity(appPaths, _loggerFactory.CreateLogger<Startup>());
+            StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
 
 
             StartupHelpers.PerformStaticInitialization();
             StartupHelpers.PerformStaticInitialization();
 
 
@@ -160,6 +161,7 @@ namespace Jellyfin.Server
                             options,
                             options,
                             startupConfig);
                             startupConfig);
             _appHost = appHost;
             _appHost = appHost;
+            var configurationCompleted = false;
             try
             try
             {
             {
                 _jellyfinHost = Host.CreateDefaultBuilder()
                 _jellyfinHost = Host.CreateDefaultBuilder()
@@ -176,6 +178,7 @@ namespace Jellyfin.Server
                     })
                     })
                     .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig))
                     .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig))
                     .UseSerilog()
                     .UseSerilog()
+                    .ConfigureServices(e => e.AddTransient<IStartupLogger, StartupLogger>().AddSingleton<IServiceCollection>(e))
                     .Build();
                     .Build();
 
 
                 // 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.
@@ -200,6 +203,7 @@ namespace Jellyfin.Server
                 await jellyfinMigrationService.CleanupSystemAfterMigration(_logger).ConfigureAwait(false);
                 await jellyfinMigrationService.CleanupSystemAfterMigration(_logger).ConfigureAwait(false);
                 try
                 try
                 {
                 {
+                    configurationCompleted = true;
                     await _setupServer!.StopAsync().ConfigureAwait(false);
                     await _setupServer!.StopAsync().ConfigureAwait(false);
                     await _jellyfinHost.StartAsync().ConfigureAwait(false);
                     await _jellyfinHost.StartAsync().ConfigureAwait(false);
 
 
@@ -228,6 +232,12 @@ namespace Jellyfin.Server
             {
             {
                 _restartOnShutdown = false;
                 _restartOnShutdown = false;
                 _logger.LogCritical(ex, "Error while starting server");
                 _logger.LogCritical(ex, "Error while starting server");
+                if (_setupServer!.IsAlive && !configurationCompleted)
+                {
+                    _setupServer!.SoftStop();
+                    await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false);
+                    await _setupServer!.StopAsync().ConfigureAwait(false);
+                }
             }
             }
             finally
             finally
             {
             {
@@ -258,13 +268,17 @@ namespace Jellyfin.Server
         /// <returns>A task.</returns>
         /// <returns>A task.</returns>
         public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig)
         public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig)
         {
         {
+            _migrationLogger = StartupLogger.Logger.BeginGroup($"Migration Service");
             var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer());
             var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer());
             startupConfigurationManager.AddParts([new DatabaseConfigurationFactory()]);
             startupConfigurationManager.AddParts([new DatabaseConfigurationFactory()]);
             var migrationStartupServiceProvider = new ServiceCollection()
             var migrationStartupServiceProvider = new ServiceCollection()
                 .AddLogging(d => d.AddSerilog())
                 .AddLogging(d => d.AddSerilog())
                 .AddJellyfinDbContext(startupConfigurationManager, startupConfig)
                 .AddJellyfinDbContext(startupConfigurationManager, startupConfig)
                 .AddSingleton<IApplicationPaths>(appPaths)
                 .AddSingleton<IApplicationPaths>(appPaths)
-                .AddSingleton<ServerApplicationPaths>(appPaths);
+                .AddSingleton<ServerApplicationPaths>(appPaths)
+                .AddSingleton<IStartupLogger>(_migrationLogger);
+
+            migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider);
             var startupService = migrationStartupServiceProvider.BuildServiceProvider();
             var startupService = migrationStartupServiceProvider.BuildServiceProvider();
 
 
             PrepareDatabaseProvider(startupService);
             PrepareDatabaseProvider(startupService);
@@ -285,7 +299,7 @@ namespace Jellyfin.Server
         /// <returns>A task.</returns>
         /// <returns>A task.</returns>
         public static async Task ApplyCoreMigrationsAsync(IServiceProvider serviceProvider, Migrations.Stages.JellyfinMigrationStageTypes jellyfinMigrationStage)
         public static async Task ApplyCoreMigrationsAsync(IServiceProvider serviceProvider, Migrations.Stages.JellyfinMigrationStageTypes jellyfinMigrationStage)
         {
         {
-            var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider);
+            var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider, _migrationLogger!);
             await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false);
             await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false);
         }
         }
 
 

+ 25 - 0
Jellyfin.Server/ServerSetupApp/IStartupLogger.cs

@@ -0,0 +1,25 @@
+using System;
+using Morestachio.Helper.Logging;
+using ILogger = Microsoft.Extensions.Logging.ILogger;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <summary>
+/// Defines the Startup Logger. This logger acts an an aggregate logger that will push though all log messages to both the attached logger as well as the startup UI.
+/// </summary>
+public interface IStartupLogger : ILogger
+{
+    /// <summary>
+    /// Adds another logger instance to this logger for combined logging.
+    /// </summary>
+    /// <param name="logger">Other logger to rely messages to.</param>
+    /// <returns>A combined logger.</returns>
+    IStartupLogger With(ILogger logger);
+
+    /// <summary>
+    /// Opens a new Group logger within the parent logger.
+    /// </summary>
+    /// <param name="logEntry">Defines the log message that introduces the new group.</param>
+    /// <returns>A new logger that can write to the group.</returns>
+    IStartupLogger BeginGroup(FormattableString logEntry);
+}

+ 165 - 9
Jellyfin.Server/ServerSetupApp/SetupServer.cs

@@ -1,4 +1,7 @@
 using System;
 using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
 using System.Net;
 using System.Net;
@@ -10,6 +13,7 @@ using Jellyfin.Networking.Manager;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller;
+using MediaBrowser.Model.IO;
 using MediaBrowser.Model.System;
 using MediaBrowser.Model.System;
 using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Hosting;
@@ -20,6 +24,9 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
 using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Primitives;
 using Microsoft.Extensions.Primitives;
+using Morestachio;
+using Morestachio.Framework.IO.SingleStream;
+using Morestachio.Rendering;
 
 
 namespace Jellyfin.Server.ServerSetupApp;
 namespace Jellyfin.Server.ServerSetupApp;
 
 
@@ -34,8 +41,10 @@ public sealed class SetupServer : IDisposable
     private readonly ILoggerFactory _loggerFactory;
     private readonly ILoggerFactory _loggerFactory;
     private readonly IConfiguration _startupConfiguration;
     private readonly IConfiguration _startupConfiguration;
     private readonly ServerConfigurationManager _configurationManager;
     private readonly ServerConfigurationManager _configurationManager;
+    private IRenderer? _startupUiRenderer;
     private IHost? _startupServer;
     private IHost? _startupServer;
     private bool _disposed;
     private bool _disposed;
+    private bool _isUnhealthy;
 
 
     /// <summary>
     /// <summary>
     /// Initializes a new instance of the <see cref="SetupServer"/> class.
     /// Initializes a new instance of the <see cref="SetupServer"/> class.
@@ -62,13 +71,73 @@ public sealed class SetupServer : IDisposable
         _configurationManager.RegisterConfiguration<NetworkConfigurationFactory>();
         _configurationManager.RegisterConfiguration<NetworkConfigurationFactory>();
     }
     }
 
 
+    internal static ConcurrentQueue<StartupLogEntry>? LogQueue { get; set; } = new();
+
+    /// <summary>
+    /// Gets a value indicating whether Startup server is currently running.
+    /// </summary>
+    public bool IsAlive { get; internal set; }
+
     /// <summary>
     /// <summary>
     /// Starts the Bind-All Setup aspcore server to provide a reflection on the current core setup.
     /// Starts the Bind-All Setup aspcore server to provide a reflection on the current core setup.
     /// </summary>
     /// </summary>
     /// <returns>A Task.</returns>
     /// <returns>A Task.</returns>
     public async Task RunAsync()
     public async Task RunAsync()
     {
     {
+        var fileTemplate = await File.ReadAllTextAsync(Path.Combine("ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
+        _startupUiRenderer = (await ParserOptionsBuilder.New()
+            .WithTemplate(fileTemplate)
+            .WithFormatter(
+                (StartupLogEntry logEntry, IEnumerable<StartupLogEntry> children) =>
+                {
+                    if (children.Any())
+                    {
+                        var maxLevel = logEntry.LogLevel;
+                        var stack = new Stack<StartupLogEntry>(children);
+
+                        while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) != null) // error is the highest inherted error level.
+                        {
+                            maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
+                            foreach (var child in logEntry.Children)
+                            {
+                                stack.Push(child);
+                            }
+                        }
+
+                        return maxLevel;
+                    }
+
+                    return logEntry.LogLevel;
+                },
+                "FormatLogLevel")
+            .WithFormatter(
+                (LogLevel logLevel) =>
+                {
+                    switch (logLevel)
+                    {
+                        case LogLevel.Trace:
+                        case LogLevel.Debug:
+                        case LogLevel.None:
+                            return "success";
+                        case LogLevel.Information:
+                            return "info";
+                        case LogLevel.Warning:
+                            return "warn";
+                        case LogLevel.Error:
+                            return "danger";
+                        case LogLevel.Critical:
+                            return "danger-strong";
+                    }
+
+                    return string.Empty;
+                },
+                "ToString")
+            .BuildAndParseAsync()
+            .ConfigureAwait(false))
+            .CreateCompiledRenderer();
+
         ThrowIfDisposed();
         ThrowIfDisposed();
+        var retryAfterValue = TimeSpan.FromSeconds(5);
         _startupServer = Host.CreateDefaultBuilder()
         _startupServer = Host.CreateDefaultBuilder()
             .UseConsoleLifetime()
             .UseConsoleLifetime()
             .ConfigureServices(serv =>
             .ConfigureServices(serv =>
@@ -140,7 +209,7 @@ public sealed class SetupServer : IDisposable
                                             if (jfApplicationHost is null)
                                             if (jfApplicationHost is null)
                                             {
                                             {
                                                 context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
                                                 context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
-                                                context.Response.Headers.RetryAfter = new StringValues("5");
+                                                context.Response.Headers.RetryAfter = new StringValues(retryAfterValue.TotalSeconds.ToString("000", CultureInfo.InvariantCulture));
                                                 return;
                                                 return;
                                             }
                                             }
 
 
@@ -158,24 +227,30 @@ public sealed class SetupServer : IDisposable
                                         });
                                         });
                                     });
                                     });
 
 
-                                    app.Run((context) =>
+                                    app.Run(async (context) =>
                                     {
                                     {
                                         context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
                                         context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
-                                        context.Response.Headers.RetryAfter = new StringValues("5");
+                                        context.Response.Headers.RetryAfter = new StringValues(retryAfterValue.TotalSeconds.ToString("000", CultureInfo.InvariantCulture));
                                         context.Response.Headers.ContentType = new StringValues("text/html");
                                         context.Response.Headers.ContentType = new StringValues("text/html");
-                                        context.Response.WriteAsync("<p>Jellyfin Server still starting. Please wait.</p>");
                                         var networkManager = _networkManagerFactory();
                                         var networkManager = _networkManagerFactory();
-                                        if (networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress))
-                                        {
-                                            context.Response.WriteAsync("<p>You can download the current logfiles <a href='/startup/logger'>here</a>.</p>");
-                                        }
 
 
-                                        return Task.CompletedTask;
+                                        var startupLogEntries = LogQueue?.ToArray() ?? [];
+                                        await _startupUiRenderer.RenderAsync(
+                                            new Dictionary<string, object>()
+                                            {
+                                                { "isInReportingMode", _isUnhealthy },
+                                                { "retryValue", retryAfterValue },
+                                                { "logs", startupLogEntries },
+                                                { "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
+                                            },
+                                            new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
+                                            .ConfigureAwait(false);
                                     });
                                     });
                                 });
                                 });
                     })
                     })
                     .Build();
                     .Build();
         await _startupServer.StartAsync().ConfigureAwait(false);
         await _startupServer.StartAsync().ConfigureAwait(false);
+        IsAlive = true;
     }
     }
 
 
     /// <summary>
     /// <summary>
@@ -191,6 +266,7 @@ public sealed class SetupServer : IDisposable
         }
         }
 
 
         await _startupServer.StopAsync().ConfigureAwait(false);
         await _startupServer.StopAsync().ConfigureAwait(false);
+        IsAlive = false;
     }
     }
 
 
     /// <inheritdoc/>
     /// <inheritdoc/>
@@ -203,6 +279,9 @@ public sealed class SetupServer : IDisposable
 
 
         _disposed = true;
         _disposed = true;
         _startupServer?.Dispose();
         _startupServer?.Dispose();
+        IsAlive = false;
+        LogQueue?.Clear();
+        LogQueue = null;
     }
     }
 
 
     private void ThrowIfDisposed()
     private void ThrowIfDisposed()
@@ -210,11 +289,88 @@ public sealed class SetupServer : IDisposable
         ObjectDisposedException.ThrowIf(_disposed, this);
         ObjectDisposedException.ThrowIf(_disposed, this);
     }
     }
 
 
+    internal void SoftStop()
+    {
+        _isUnhealthy = true;
+    }
+
     private class SetupHealthcheck : IHealthCheck
     private class SetupHealthcheck : IHealthCheck
     {
     {
+        private readonly SetupServer _startupServer;
+
+        public SetupHealthcheck(SetupServer startupServer)
+        {
+            _startupServer = startupServer;
+        }
+
         public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
         public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
         {
         {
+            if (_startupServer._isUnhealthy)
+            {
+                return Task.FromResult(HealthCheckResult.Unhealthy("Server is could not complete startup. Check logs."));
+            }
+
             return Task.FromResult(HealthCheckResult.Degraded("Server is still starting up."));
             return Task.FromResult(HealthCheckResult.Degraded("Server is still starting up."));
         }
         }
     }
     }
+
+    internal sealed class SetupLoggerFactory : ILoggerProvider, IDisposable
+    {
+        private bool _disposed;
+
+        public ILogger CreateLogger(string categoryName)
+        {
+            return new CatchingSetupServerLogger();
+        }
+
+        public void Dispose()
+        {
+            if (_disposed)
+            {
+                return;
+            }
+
+            _disposed = true;
+        }
+    }
+
+    internal sealed class CatchingSetupServerLogger : ILogger
+    {
+        public IDisposable? BeginScope<TState>(TState state)
+            where TState : notnull
+        {
+            return null;
+        }
+
+        public bool IsEnabled(LogLevel logLevel)
+        {
+            return logLevel is LogLevel.Error or LogLevel.Critical;
+        }
+
+        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
+        {
+            if (!IsEnabled(logLevel))
+            {
+                return;
+            }
+
+            LogQueue?.Enqueue(new()
+            {
+                LogLevel = logLevel,
+                Content = formatter(state, exception),
+                DateOfCreation = DateTimeOffset.Now
+            });
+        }
+    }
+
+    internal class StartupLogEntry
+    {
+        public LogLevel LogLevel { get; set; }
+
+        public string? Content { get; set; }
+
+        public DateTimeOffset DateOfCreation { get; set; }
+
+        public List<StartupLogEntry> Children { get; set; } = [];
+    }
 }
 }

+ 102 - 0
Jellyfin.Server/ServerSetupApp/StartupLogger.cs

@@ -0,0 +1,102 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Server.Migrations.Routines;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <inheritdoc/>
+public class StartupLogger : IStartupLogger
+{
+    private readonly SetupServer.StartupLogEntry? _groupEntry;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="StartupLogger"/> class.
+    /// </summary>
+    public StartupLogger()
+    {
+        Loggers = [];
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="StartupLogger"/> class.
+    /// </summary>
+    private StartupLogger(SetupServer.StartupLogEntry? groupEntry) : this()
+    {
+        _groupEntry = groupEntry;
+    }
+
+    internal static IStartupLogger Logger { get; } = new StartupLogger();
+
+    private List<ILogger> Loggers { get; set; }
+
+    /// <inheritdoc/>
+    public IStartupLogger BeginGroup(FormattableString logEntry)
+    {
+        var startupEntry = new SetupServer.StartupLogEntry()
+        {
+            Content = logEntry.ToString(CultureInfo.InvariantCulture),
+            DateOfCreation = DateTimeOffset.Now
+        };
+
+        if (_groupEntry is null)
+        {
+            SetupServer.LogQueue?.Enqueue(startupEntry);
+        }
+        else
+        {
+            _groupEntry.Children.Add(startupEntry);
+        }
+
+        return new StartupLogger(startupEntry);
+    }
+
+    /// <inheritdoc/>
+    public IDisposable? BeginScope<TState>(TState state)
+        where TState : notnull
+    {
+        return null;
+    }
+
+    /// <inheritdoc/>
+    public bool IsEnabled(LogLevel logLevel)
+    {
+        return true;
+    }
+
+    /// <inheritdoc/>
+    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
+    {
+        foreach (var item in Loggers.Where(e => e.IsEnabled(logLevel)))
+        {
+            item.Log(logLevel, eventId, state, exception, formatter);
+        }
+
+        var startupEntry = new SetupServer.StartupLogEntry()
+        {
+            LogLevel = logLevel,
+            Content = formatter(state, exception),
+            DateOfCreation = DateTimeOffset.Now
+        };
+
+        if (_groupEntry is null)
+        {
+            SetupServer.LogQueue?.Enqueue(startupEntry);
+        }
+        else
+        {
+            _groupEntry.Children.Add(startupEntry);
+        }
+    }
+
+    /// <inheritdoc/>
+    public IStartupLogger With(ILogger logger)
+    {
+        return new StartupLogger(_groupEntry)
+        {
+            Loggers = [.. Loggers, logger]
+        };
+    }
+}

+ 225 - 0
Jellyfin.Server/ServerSetupApp/index.mstemplate.html

@@ -0,0 +1,225 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <meta charset="UTF-8" />
+    <title>
+        {{#IF isInReportingMode}}
+        ❌
+        {{/IF}}
+        Jellyfin Startup
+    </title>
+    <style>
+        * {
+            font-family: sans-serif;
+        }
+
+        .flex-row {
+            display: flex;
+            flex-direction: row;
+            flex-wrap: nowrap;
+            justify-content: center;
+            align-items: center;
+            align-content: normal;
+        }
+
+        .flex-col {
+            display: flex;
+            flex-direction: column;
+            flex-wrap: nowrap;
+            justify-content: center;
+            align-items: center;
+            align-content: normal;
+        }
+
+        header {
+            height: 5rem;
+            width: 100%;
+        }
+
+        header svg {
+            height: 3rem;
+            width: 9rem;
+            margin-right: 1rem;
+        }
+
+        /* ol.action-list {
+            list-style-type: none;
+            position: relative;
+        } */
+
+        ol.action-list * {
+            font-family: monospace;
+            font-weight: 300;
+            font-size: clamp(18px, 100vw / var(--width), 20px);
+            font-feature-settings: 'onum', 'pnum';
+            line-height: 1.8;
+            -webkit-text-size-adjust: none;
+        }
+
+        /*
+        ol.action-list li {
+            padding-top: .5rem;
+        }
+
+        ol.action-list li::before {
+            position: absolute;
+            left: -0.8em;
+            font-size: 1.1em;
+        } */
+
+        /* Attribution as heavily inspired by: https://iamkate.com/code/tree-views/ */
+        .action-list {
+            --spacing: 1.4rem;
+            --radius: 14px;
+        }
+
+        .action-list li {
+            display: block;
+            position: relative;
+            padding-left: calc(2 * var(--spacing) - var(--radius) - 1px);
+        }
+
+        .action-list ul {
+            margin-left: calc(var(--radius) - var(--spacing));
+            padding-left: 0;
+        }
+
+        .action-list ul li {
+            border-left: 2px solid #ddd;
+        }
+
+        .action-list ul li:last-child {
+            border-color: transparent;
+        }
+
+        .action-list ul li::before {
+            content: '';
+            display: block;
+            position: absolute;
+            top: calc(var(--spacing) / -2);
+            left: -2px;
+            width: calc(var(--spacing) + 2px);
+            height: calc(var(--spacing) + 1px);
+            border: solid #ddd;
+            border-width: 0 0 2px 2px;
+        }
+
+        .action-list summary {
+            display: block;
+            cursor: pointer;
+        }
+
+        .action-list summary::marker,
+        .action-list summary::-webkit-details-marker {
+            display: none;
+        }
+
+        .action-list summary:focus {
+            outline: none;
+        }
+
+        .action-list summary:focus-visible {
+            outline: 1px dotted #000;
+        }
+
+        .action-list li::after,
+        .action-list summary::before {
+            content: '';
+            display: block;
+            position: absolute;
+            top: calc(var(--spacing) / 2 - var(--radius) + 4px);
+            left: calc(var(--spacing) - var(--radius) - -5px);
+        }
+
+        .action-list summary::before {
+            z-index: 1;
+            /* background: #696 url('expand-collapse.svg') 0 0; */
+        }
+
+        .action-list details[open]>summary::before {
+            background-position: calc(-2 * var(--radius)) 0;
+        }
+
+        .action-list li.danger-item::after,
+        .action-list li.danger-strong-item::after {
+            content: '❌';
+        }
+
+        ol.action-list li span.danger-strong-item {
+            text-decoration-style: solid;
+            text-decoration-color: red;
+            text-decoration-line: underline;
+        }
+
+        ol.action-list li.warn-item::after {
+            content: '⚠️';
+        }
+
+        ol.action-list li.success-item::after {
+            content: '✅';
+        }
+
+        ol.action-list li.info-item::after {
+            content: '🔹';
+        }
+
+        /* End Attribution */
+    </style>
+</head>
+
+<body>
+    <div>
+        <header class="flex-row">
+
+            {{^IF isInReportingMode}}
+            <p>Jellyfin Server still starting. Please wait.</p>
+            {{#ELSE}}
+            <p>Jellyfin Server has encountered an error and was not able to start.</p>
+            {{/ELSE}}
+            {{/IF}}
+
+            {{#IF localNetworkRequest}}
+            <p style="margin-left: 1rem;">You can download the current log file <a href='/startup/logger'
+                    target="_blank">here</a>.</p>
+            {{/IF}}
+        </header>
+
+        {{#DECLARE LogEntry |--}}
+        {{#LET children = Children}}
+        <li class="{{FormatLogLevel(children).ToString()}}-item">
+            {{--| #IF children.Count > 0}}
+            <details open>
+                <summary>{{DateOfCreation}} - {{Content}}</summary>
+                <ul class="action-list">
+                    {{--| #EACH children.Reverse() |-}}
+                    {{#IMPORT 'LogEntry'}}
+                    {{--| /EACH |-}}
+                </ul>
+            </details>
+            {{--| #ELSE |-}}
+            <span class="{{FormatLogLevel(children).ToString()}}-item">{{DateOfCreation}} - {{Content}}</span>
+            {{--| /ELSE |--}}
+            {{--| /IF |-}}
+        </li>
+        {{--| /DECLARE}}
+
+        <div class="flex-col">
+            <ol class="action-list">
+                {{#FOREACH log IN logs.Reverse()}}
+                {{#IMPORT 'LogEntry' #WITH log}}
+                {{/FOREACH}}
+            </ol>
+        </div>
+    </div>
+</body>
+
+{{^IF isInReportingMode}}
+<script>
+    setTimeout(() => {
+        window.location.reload();
+    }, {{ retryValue.TotalMilliseconds }});
+</script>
+{{/IF}}
+
+</html>

+ 38 - 1
tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs

@@ -5,6 +5,7 @@ using System.IO;
 using Emby.Server.Implementations;
 using Emby.Server.Implementations;
 using Jellyfin.Server.Extensions;
 using Jellyfin.Server.Extensions;
 using Jellyfin.Server.Helpers;
 using Jellyfin.Server.Helpers;
+using Jellyfin.Server.ServerSetupApp;
 using MediaBrowser.Common;
 using MediaBrowser.Common;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Hosting;
@@ -16,6 +17,7 @@ using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
 using Microsoft.Extensions.Logging.Abstractions;
 using Moq;
 using Moq;
 using Serilog;
 using Serilog;
+using Serilog.Core;
 using Serilog.Extensions.Logging;
 using Serilog.Extensions.Logging;
 
 
 namespace Jellyfin.Server.Integration.Tests
 namespace Jellyfin.Server.Integration.Tests
@@ -95,7 +97,8 @@ namespace Jellyfin.Server.Integration.Tests
                         .AddInMemoryCollection(ConfigurationOptions.DefaultConfiguration)
                         .AddInMemoryCollection(ConfigurationOptions.DefaultConfiguration)
                         .AddEnvironmentVariables("JELLYFIN_")
                         .AddEnvironmentVariables("JELLYFIN_")
                         .AddInMemoryCollection(commandLineOpts.ConvertToConfig());
                         .AddInMemoryCollection(commandLineOpts.ConvertToConfig());
-                });
+                })
+                .ConfigureServices(e => e.AddSingleton<IStartupLogger, NullStartupLogger>().AddSingleton(e));
         }
         }
 
 
         /// <inheritdoc/>
         /// <inheritdoc/>
@@ -128,5 +131,39 @@ namespace Jellyfin.Server.Integration.Tests
 
 
             base.Dispose(disposing);
             base.Dispose(disposing);
         }
         }
+
+        private sealed class NullStartupLogger : IStartupLogger
+        {
+            public IStartupLogger BeginGroup(FormattableString logEntry)
+            {
+                return this;
+            }
+
+            public IDisposable? BeginScope<TState>(TState state)
+                where TState : notnull
+            {
+                return NullLogger.Instance.BeginScope(state);
+            }
+
+            public bool IsEnabled(LogLevel logLevel)
+            {
+                return NullLogger.Instance.IsEnabled(logLevel);
+            }
+
+            public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
+            {
+                NullLogger.Instance.Log(logLevel, eventId, state, exception, formatter);
+            }
+
+            public Microsoft.Extensions.Logging.ILogger With(Microsoft.Extensions.Logging.ILogger logger)
+            {
+                return this;
+            }
+
+            IStartupLogger IStartupLogger.With(Microsoft.Extensions.Logging.ILogger logger)
+            {
+                return this;
+            }
+        }
     }
     }
 }
 }