diff --git a/Directory.Packages.props b/Directory.Packages.props index d863d99fb6..a0a09d7456 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -51,6 +51,7 @@ + diff --git a/Jellyfin.Server/Helpers/StartupHelpers.cs b/Jellyfin.Server/Helpers/StartupHelpers.cs index bbf6d31f1f..93c9961664 100644 --- a/Jellyfin.Server/Helpers/StartupHelpers.cs +++ b/Jellyfin.Server/Helpers/StartupHelpers.cs @@ -3,18 +3,19 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using System.Net; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; using System.Threading.Tasks; using Emby.Server.Implementations; +using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Extensions; using MediaBrowser.Model.IO; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Serilog; +using Serilog.Extensions.Logging; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Jellyfin.Server.Helpers; @@ -257,11 +258,14 @@ public static class StartupHelpers { try { + var startupLogger = new LoggerProviderCollection(); + startupLogger.AddProvider(new SetupServer.SetupLoggerFactory()); // Serilog.Log is used by SerilogLoggerFactory when no logger is specified Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(configuration) .Enrich.FromLogContext() .Enrich.WithThreadId() + .WriteTo.Async(e => e.Providers(startupLogger)) .CreateLogger(); } catch (Exception ex) diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 452b03efbe..14c4285fe2 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -48,6 +48,7 @@ + @@ -79,6 +80,9 @@ PreserveNewest + + PreserveNewest + diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index 5c5c64ec38..5331b43e33 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -10,13 +10,13 @@ using Emby.Server.Implementations.Serialization; using Jellyfin.Database.Implementations; using Jellyfin.Server.Implementations.SystemBackupService; using Jellyfin.Server.Migrations.Stages; +using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.SystemBackupService; using MediaBrowser.Model.Configuration; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations; @@ -29,6 +29,7 @@ internal class JellyfinMigrationService private const string DbFilename = "library.db"; private readonly IDbContextFactory _dbContextFactory; private readonly ILoggerFactory _loggerFactory; + private readonly IStartupLogger _startupLogger; private readonly IBackupService? _backupService; private readonly IJellyfinDatabaseProvider? _jellyfinDatabaseProvider; private readonly IApplicationPaths _applicationPaths; @@ -39,18 +40,21 @@ internal class JellyfinMigrationService /// /// Provides access to the jellyfin database. /// The logger factory. + /// The startup logger for Startup UI intigration. /// Application paths for library.db backup. /// The jellyfin backup service. /// The jellyfin database provider. public JellyfinMigrationService( IDbContextFactory dbContextFactory, ILoggerFactory loggerFactory, + IStartupLogger startupLogger, IApplicationPaths applicationPaths, IBackupService? backupService = null, IJellyfinDatabaseProvider? jellyfinDatabaseProvider = null) { _dbContextFactory = dbContextFactory; _loggerFactory = loggerFactory; + _startupLogger = startupLogger; _backupService = backupService; _jellyfinDatabaseProvider = jellyfinDatabaseProvider; _applicationPaths = applicationPaths; @@ -80,14 +84,14 @@ internal class JellyfinMigrationService private interface IInternalMigration { - Task PerformAsync(ILogger logger); + Task PerformAsync(IStartupLogger logger); } private HashSet Migrations { get; set; } public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths) { - var logger = _loggerFactory.CreateLogger(); + var logger = _startupLogger.With(_loggerFactory.CreateLogger()).BeginGroup($"Migration Startup"); logger.LogInformation("Initialise Migration service."); var xmlSerializer = new MyXmlSerializer(); var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath) @@ -173,8 +177,7 @@ internal class JellyfinMigrationService public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider) { - var logger = _loggerFactory.CreateLogger(); - logger.LogInformation("Migrate stage {Stage}.", stage); + var logger = _startupLogger.With(_loggerFactory.CreateLogger()).BeginGroup($"Migrate stage {stage}."); ICollection migrationStage = (Migrations.FirstOrDefault(e => e.Stage == stage) as ICollection) ?? []; var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); @@ -202,21 +205,23 @@ internal class JellyfinMigrationService foreach (var item in migrations) { + var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); 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) { - 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.LibraryDb is not null) { - logger.LogInformation("Attempt to rollback librarydb."); + migrationLogger.LogInformation("Attempt to rollback librarydb."); try { var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename); @@ -224,33 +229,33 @@ internal class JellyfinMigrationService } 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) { - logger.LogInformation("Attempt to rollback JellyfinDb."); + migrationLogger.LogInformation("Attempt to rollback JellyfinDb."); try { await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false); } 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) { - logger.LogInformation("Attempt to rollback from backup."); + migrationLogger.LogInformation("Attempt to rollback from backup."); try { await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false); } 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; } - 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(); var createScript = historyRepository.GetInsertScript(new HistoryRow(_codeMigration.BuildCodeMigrationId(), GetJellyfinVersion())); @@ -437,7 +442,7 @@ internal class JellyfinMigrationService _jellyfinDbContext = jellyfinDbContext; } - public async Task PerformAsync(ILogger logger) + public async Task PerformAsync(IStartupLogger logger) { var migrator = _jellyfinDbContext.GetService(); await migrator.MigrateAsync(_databaseMigrationInfo.Key).ConfigureAwait(false); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs index 03a5212585..033045e639 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs @@ -9,6 +9,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions.Json; +using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using Microsoft.EntityFrameworkCore; @@ -22,7 +23,7 @@ namespace Jellyfin.Server.Migrations.Routines; [JellyfinMigration("2025-04-21T00:00:00", nameof(MigrateKeyframeData))] public class MigrateKeyframeData : IDatabaseMigrationRoutine { - private readonly ILogger _logger; + private readonly IStartupLogger _logger; private readonly IApplicationPaths _appPaths; private readonly IDbContextFactory _dbProvider; private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; @@ -30,15 +31,15 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine /// /// Initializes a new instance of the class. /// - /// The logger. + /// The startup logger for Startup UI intigration. /// Instance of the interface. /// The EFCore db factory. public MigrateKeyframeData( - ILogger logger, + IStartupLogger startupLogger, IApplicationPaths appPaths, IDbContextFactory dbProvider) { - _logger = logger; + _logger = startupLogger; _appPaths = appPaths; _dbProvider = dbProvider; } diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs index 309858ca70..521655a4f1 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs @@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using Jellyfin.Server.Implementations.Item; +using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Entities; @@ -34,7 +35,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine { private const string DbFilename = "library.db"; - private readonly ILogger _logger; + private readonly IStartupLogger _logger; private readonly IServerApplicationPaths _paths; private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; private readonly IDbContextFactory _provider; @@ -42,19 +43,17 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine /// /// Initializes a new instance of the class. /// - /// The logger. + /// The startup logger for Startup UI intigration. /// The database provider. /// The server application paths. /// The database provider for special access. - /// The Service provider. public MigrateLibraryDb( - ILogger logger, + IStartupLogger startupLogger, IDbContextFactory provider, IServerApplicationPaths paths, - IJellyfinDatabaseProvider jellyfinDatabaseProvider, - IServiceProvider serviceProvider) + IJellyfinDatabaseProvider jellyfinDatabaseProvider) { - _logger = logger; + _logger = startupLogger; _provider = provider; _paths = paths; _jellyfinDatabaseProvider = jellyfinDatabaseProvider; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs new file mode 100644 index 0000000000..2d5fc2a0db --- /dev/null +++ b/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; + +/// +/// The migration routine for checking if the current instance of Jellyfin is compatiable to be upgraded. +/// +[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; + + /// + /// Initializes a new instance of the class. + /// + /// The startup logger. + /// The Path service. + public MigrateLibraryDbCompatibilityCheck(IStartupLogger startupLogger, IServerApplicationPaths paths) + { + _logger = startupLogger; + _paths = paths; + } + + /// + 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"); + } + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs index 9aed449882..ae93557de8 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Model.Globalization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -16,18 +17,18 @@ namespace Jellyfin.Server.Migrations.Routines; #pragma warning restore CS0618 // Type or member is obsolete internal class MigrateRatingLevels : IDatabaseMigrationRoutine { - private readonly ILogger _logger; + private readonly IStartupLogger _logger; private readonly IDbContextFactory _provider; private readonly ILocalizationManager _localizationManager; public MigrateRatingLevels( IDbContextFactory provider, - ILoggerFactory loggerFactory, + IStartupLogger logger, ILocalizationManager localizationManager) { _provider = provider; _localizationManager = localizationManager; - _logger = loggerFactory.CreateLogger(); + _logger = logger; } /// diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs index 38952eec96..6f650f7313 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs @@ -13,6 +13,7 @@ using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.IO; @@ -29,7 +30,7 @@ namespace Jellyfin.Server.Migrations.Routines; public class MoveExtractedFiles : IAsyncMigrationRoutine { private readonly IApplicationPaths _appPaths; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IDbContextFactory _dbProvider; private readonly IPathManager _pathManager; private readonly IFileSystem _fileSystem; @@ -39,18 +40,20 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine /// /// Instance of the interface. /// The logger. + /// The startup logger for Startup UI intigration. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. public MoveExtractedFiles( IApplicationPaths appPaths, ILogger logger, + IStartupLogger startupLogger, IPathManager pathManager, IFileSystem fileSystem, IDbContextFactory dbProvider) { _appPaths = appPaths; - _logger = logger; + _logger = startupLogger.With(logger); _pathManager = pathManager; _fileSystem = fileSystem; _dbProvider = dbProvider; diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs index 63b0614fd4..a674aa928b 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using Jellyfin.Data.Enums; +using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Trickplay; @@ -23,7 +24,7 @@ public class MoveTrickplayFiles : IMigrationRoutine private readonly ITrickplayManager _trickplayManager; private readonly IFileSystem _fileSystem; private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; + private readonly IStartupLogger _logger; /// /// Initializes a new instance of the class. @@ -36,7 +37,7 @@ public class MoveTrickplayFiles : IMigrationRoutine ITrickplayManager trickplayManager, IFileSystem fileSystem, ILibraryManager libraryManager, - ILogger logger) + IStartupLogger logger) { _trickplayManager = trickplayManager; _fileSystem = fileSystem; diff --git a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs index addbb69bfd..47ed26965b 100644 --- a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs +++ b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs @@ -2,7 +2,9 @@ using System; using System.Globalization; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Server.ServerSetupApp; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Jellyfin.Server.Migrations.Stages; @@ -16,10 +18,34 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta 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()) + { + 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 if (typeof(IMigrationRoutine).IsAssignableFrom(MigrationType)) @@ -30,7 +56,8 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta } 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 } } @@ -42,7 +69,8 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta } 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 diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 9f2c71ce25..0b77d63ac7 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -60,6 +60,7 @@ namespace Jellyfin.Server private static long _startTimestamp; private static ILogger _logger = NullLogger.Instance; private static bool _restartOnShutdown; + private static IStartupLogger? _migrationLogger; private static string? _restoreFromBackup; /// @@ -98,9 +99,9 @@ namespace Jellyfin.Server // Create an instance of the application configuration to use for application startup IConfiguration startupConfig = CreateAppConfiguration(options, appPaths); + StartupHelpers.InitializeLoggingFramework(startupConfig, appPaths); _setupServer = new SetupServer(static () => _jellyfinHost?.Services?.GetService(), appPaths, static () => _appHost, _loggerFactory, startupConfig); await _setupServer.RunAsync().ConfigureAwait(false); - StartupHelpers.InitializeLoggingFramework(startupConfig, appPaths); _logger = _loggerFactory.CreateLogger("Main"); // Use the logging framework for uncaught exceptions instead of std error @@ -131,7 +132,7 @@ namespace Jellyfin.Server } } - StorageHelper.TestCommonPathsForStorageCapacity(appPaths, _loggerFactory.CreateLogger()); + StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger()).BeginGroup($"Storage Check")); StartupHelpers.PerformStaticInitialization(); @@ -160,6 +161,7 @@ namespace Jellyfin.Server options, startupConfig); _appHost = appHost; + var configurationCompleted = false; try { _jellyfinHost = Host.CreateDefaultBuilder() @@ -176,6 +178,7 @@ namespace Jellyfin.Server }) .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig)) .UseSerilog() + .ConfigureServices(e => e.AddTransient().AddSingleton(e)) .Build(); // 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); try { + configurationCompleted = true; await _setupServer!.StopAsync().ConfigureAwait(false); await _jellyfinHost.StartAsync().ConfigureAwait(false); @@ -228,6 +232,12 @@ namespace Jellyfin.Server { _restartOnShutdown = false; _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 { @@ -258,13 +268,17 @@ namespace Jellyfin.Server /// A task. public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig) { + _migrationLogger = StartupLogger.Logger.BeginGroup($"Migration Service"); var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer()); startupConfigurationManager.AddParts([new DatabaseConfigurationFactory()]); var migrationStartupServiceProvider = new ServiceCollection() .AddLogging(d => d.AddSerilog()) .AddJellyfinDbContext(startupConfigurationManager, startupConfig) .AddSingleton(appPaths) - .AddSingleton(appPaths); + .AddSingleton(appPaths) + .AddSingleton(_migrationLogger); + + migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider); var startupService = migrationStartupServiceProvider.BuildServiceProvider(); PrepareDatabaseProvider(startupService); @@ -285,7 +299,7 @@ namespace Jellyfin.Server /// A task. public static async Task ApplyCoreMigrationsAsync(IServiceProvider serviceProvider, Migrations.Stages.JellyfinMigrationStageTypes jellyfinMigrationStage) { - var jellyfinMigrationService = ActivatorUtilities.CreateInstance(serviceProvider); + var jellyfinMigrationService = ActivatorUtilities.CreateInstance(serviceProvider, _migrationLogger!); await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false); } diff --git a/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs new file mode 100644 index 0000000000..2c2ef05f8a --- /dev/null +++ b/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; + +/// +/// 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. +/// +public interface IStartupLogger : ILogger +{ + /// + /// Adds another logger instance to this logger for combined logging. + /// + /// Other logger to rely messages to. + /// A combined logger. + IStartupLogger With(ILogger logger); + + /// + /// Opens a new Group logger within the parent logger. + /// + /// Defines the log message that introduces the new group. + /// A new logger that can write to the group. + IStartupLogger BeginGroup(FormattableString logEntry); +} diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 7ab5defc8a..751cf7f428 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Net; @@ -10,6 +13,7 @@ using Jellyfin.Networking.Manager; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller; +using MediaBrowser.Model.IO; using MediaBrowser.Model.System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -20,6 +24,9 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; +using Morestachio; +using Morestachio.Framework.IO.SingleStream; +using Morestachio.Rendering; namespace Jellyfin.Server.ServerSetupApp; @@ -34,8 +41,10 @@ public sealed class SetupServer : IDisposable private readonly ILoggerFactory _loggerFactory; private readonly IConfiguration _startupConfiguration; private readonly ServerConfigurationManager _configurationManager; + private IRenderer? _startupUiRenderer; private IHost? _startupServer; private bool _disposed; + private bool _isUnhealthy; /// /// Initializes a new instance of the class. @@ -62,13 +71,73 @@ public sealed class SetupServer : IDisposable _configurationManager.RegisterConfiguration(); } + internal static ConcurrentQueue? LogQueue { get; set; } = new(); + + /// + /// Gets a value indicating whether Startup server is currently running. + /// + public bool IsAlive { get; internal set; } + /// /// Starts the Bind-All Setup aspcore server to provide a reflection on the current core setup. /// /// A Task. 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 children) => + { + if (children.Any()) + { + var maxLevel = logEntry.LogLevel; + var stack = new Stack(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(); + var retryAfterValue = TimeSpan.FromSeconds(5); _startupServer = Host.CreateDefaultBuilder() .UseConsoleLifetime() .ConfigureServices(serv => @@ -140,7 +209,7 @@ public sealed class SetupServer : IDisposable if (jfApplicationHost is null) { 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; } @@ -158,24 +227,30 @@ public sealed class SetupServer : IDisposable }); }); - app.Run((context) => + app.Run(async (context) => { 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.WriteAsync("

Jellyfin Server still starting. Please wait.

"); var networkManager = _networkManagerFactory(); - if (networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress)) - { - context.Response.WriteAsync("

You can download the current logfiles here.

"); - } - return Task.CompletedTask; + var startupLogEntries = LogQueue?.ToArray() ?? []; + await _startupUiRenderer.RenderAsync( + new Dictionary() + { + { "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(); await _startupServer.StartAsync().ConfigureAwait(false); + IsAlive = true; } /// @@ -191,6 +266,7 @@ public sealed class SetupServer : IDisposable } await _startupServer.StopAsync().ConfigureAwait(false); + IsAlive = false; } /// @@ -203,6 +279,9 @@ public sealed class SetupServer : IDisposable _disposed = true; _startupServer?.Dispose(); + IsAlive = false; + LogQueue?.Clear(); + LogQueue = null; } private void ThrowIfDisposed() @@ -210,11 +289,88 @@ public sealed class SetupServer : IDisposable ObjectDisposedException.ThrowIf(_disposed, this); } + internal void SoftStop() + { + _isUnhealthy = true; + } + private class SetupHealthcheck : IHealthCheck { + private readonly SetupServer _startupServer; + + public SetupHealthcheck(SetupServer startupServer) + { + _startupServer = startupServer; + } + public Task 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.")); } } + + 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 state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return logLevel is LogLevel.Error or LogLevel.Critical; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func 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 Children { get; set; } = []; + } } diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs new file mode 100644 index 0000000000..2b86dc0c1a --- /dev/null +++ b/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; + +/// +public class StartupLogger : IStartupLogger +{ + private readonly SetupServer.StartupLogEntry? _groupEntry; + + /// + /// Initializes a new instance of the class. + /// + public StartupLogger() + { + Loggers = []; + } + + /// + /// Initializes a new instance of the class. + /// + private StartupLogger(SetupServer.StartupLogEntry? groupEntry) : this() + { + _groupEntry = groupEntry; + } + + internal static IStartupLogger Logger { get; } = new StartupLogger(); + + private List Loggers { get; set; } + + /// + 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); + } + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + /// + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func 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); + } + } + + /// + public IStartupLogger With(ILogger logger) + { + return new StartupLogger(_groupEntry) + { + Loggers = [.. Loggers, logger] + }; + } +} diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html new file mode 100644 index 0000000000..747835b2a8 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -0,0 +1,225 @@ + + + + + + + {{#IF isInReportingMode}} + ❌ + {{/IF}} + Jellyfin Startup + + + + + +
+
+ + {{^IF isInReportingMode}} +

Jellyfin Server still starting. Please wait.

+ {{#ELSE}} +

Jellyfin Server has encountered an error and was not able to start.

+ {{/ELSE}} + {{/IF}} + + {{#IF localNetworkRequest}} +

You can download the current log file here.

+ {{/IF}} +
+ + {{#DECLARE LogEntry |--}} + {{#LET children = Children}} +
  • + {{--| #IF children.Count > 0}} +
    + {{DateOfCreation}} - {{Content}} +
      + {{--| #EACH children.Reverse() |-}} + {{#IMPORT 'LogEntry'}} + {{--| /EACH |-}} +
    +
    + {{--| #ELSE |-}} + {{DateOfCreation}} - {{Content}} + {{--| /ELSE |--}} + {{--| /IF |-}} +
  • + {{--| /DECLARE}} + +
    +
      + {{#FOREACH log IN logs.Reverse()}} + {{#IMPORT 'LogEntry' #WITH log}} + {{/FOREACH}} +
    +
    +
    + + +{{^IF isInReportingMode}} + +{{/IF}} + + diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index b2cde2aabc..725e359d7d 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -5,6 +5,7 @@ using System.IO; using Emby.Server.Implementations; using Jellyfin.Server.Extensions; using Jellyfin.Server.Helpers; +using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using Microsoft.AspNetCore.Hosting; @@ -16,6 +17,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Serilog; +using Serilog.Core; using Serilog.Extensions.Logging; namespace Jellyfin.Server.Integration.Tests @@ -95,7 +97,8 @@ namespace Jellyfin.Server.Integration.Tests .AddInMemoryCollection(ConfigurationOptions.DefaultConfiguration) .AddEnvironmentVariables("JELLYFIN_") .AddInMemoryCollection(commandLineOpts.ConvertToConfig()); - }); + }) + .ConfigureServices(e => e.AddSingleton().AddSingleton(e)); } /// @@ -128,5 +131,39 @@ namespace Jellyfin.Server.Integration.Tests base.Dispose(disposing); } + + private sealed class NullStartupLogger : IStartupLogger + { + public IStartupLogger BeginGroup(FormattableString logEntry) + { + return this; + } + + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return NullLogger.Instance.BeginScope(state); + } + + public bool IsEnabled(LogLevel logLevel) + { + return NullLogger.Instance.IsEnabled(logLevel); + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func 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; + } + } } }