From 1e9e4ffda9abe30b71ceb1de2f4c3143805c66a9 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Mon, 9 Jun 2025 04:52:39 +0300 Subject: [PATCH] Rework startup topic handling and reenable output to logging framework (#14243) --- .../Migrations/JellyfinMigrationService.cs | 2 +- .../Routines/MigrateKeyframeData.cs | 2 +- .../Migrations/Routines/MigrateLibraryDb.cs | 2 +- .../MigrateLibraryDbCompatibilityCheck.cs | 2 +- .../Routines/MigrateRatingLevels.cs | 2 +- .../Migrations/Routines/MoveExtractedFiles.cs | 2 +- .../Migrations/Routines/MoveTrickplayFiles.cs | 2 +- .../Migrations/Stages/CodeMigration.cs | 18 ++++- Jellyfin.Server/Program.cs | 11 ++- .../ServerSetupApp/IStartupLogger.cs | 43 +++++++++- Jellyfin.Server/ServerSetupApp/SetupServer.cs | 17 +--- .../ServerSetupApp/StartupLogTopic.cs | 31 ++++++++ .../ServerSetupApp/StartupLogger.cs | 78 ++++++++++++------- .../ServerSetupApp/StartupLoggerExtensions.cs | 18 +++++ .../ServerSetupApp/StartupLoggerOfCategory.cs | 56 +++++++++++++ .../JellyfinApplicationFactory.cs | 29 ++++++- 16 files changed, 255 insertions(+), 60 deletions(-) create mode 100644 Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs create mode 100644 Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs create mode 100644 Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index 5331b43e33..31a2201185 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -47,7 +47,7 @@ internal class JellyfinMigrationService public JellyfinMigrationService( IDbContextFactory dbContextFactory, ILoggerFactory loggerFactory, - IStartupLogger startupLogger, + IStartupLogger startupLogger, IApplicationPaths applicationPaths, IBackupService? backupService = null, IJellyfinDatabaseProvider? jellyfinDatabaseProvider = null) diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs index 033045e639..c199ee4d6b 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs @@ -35,7 +35,7 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine /// Instance of the interface. /// The EFCore db factory. public MigrateKeyframeData( - IStartupLogger startupLogger, + IStartupLogger startupLogger, IApplicationPaths appPaths, IDbContextFactory dbProvider) { diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs index 521655a4f1..0953030fad 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs @@ -48,7 +48,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine /// The server application paths. /// The database provider for special access. public MigrateLibraryDb( - IStartupLogger startupLogger, + IStartupLogger startupLogger, IDbContextFactory provider, IServerApplicationPaths paths, IJellyfinDatabaseProvider jellyfinDatabaseProvider) diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs index 2d5fc2a0db..d4cc9bbeed 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs @@ -26,7 +26,7 @@ public class MigrateLibraryDbCompatibilityCheck : IAsyncMigrationRoutine /// /// The startup logger. /// The Path service. - public MigrateLibraryDbCompatibilityCheck(IStartupLogger startupLogger, IServerApplicationPaths paths) + public MigrateLibraryDbCompatibilityCheck(IStartupLogger startupLogger, IServerApplicationPaths paths) { _logger = startupLogger; _paths = paths; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs index ae93557de8..2a6db01cf3 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs @@ -23,7 +23,7 @@ internal class MigrateRatingLevels : IDatabaseMigrationRoutine public MigrateRatingLevels( IDbContextFactory provider, - IStartupLogger logger, + IStartupLogger logger, ILocalizationManager localizationManager) { _provider = provider; diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs index 6f650f7313..8b394dd7aa 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs @@ -47,7 +47,7 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine public MoveExtractedFiles( IApplicationPaths appPaths, ILogger logger, - IStartupLogger startupLogger, + IStartupLogger startupLogger, IPathManager pathManager, IFileSystem fileSystem, IDbContextFactory dbProvider) diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs index a674aa928b..0f55465e86 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs @@ -37,7 +37,7 @@ public class MoveTrickplayFiles : IMigrationRoutine ITrickplayManager trickplayManager, IFileSystem fileSystem, ILibraryManager libraryManager, - IStartupLogger logger) + IStartupLogger logger) { _trickplayManager = trickplayManager; _fileSystem = fileSystem; diff --git a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs index 47ed26965b..c3592f62a1 100644 --- a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs +++ b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Jellyfin.Server.ServerSetupApp; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations.Stages; @@ -21,11 +22,13 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!; } - private ServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger) + private IServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger) { - var childServiceCollection = new ServiceCollection(); - childServiceCollection.AddSingleton(serviceProvider); - childServiceCollection.AddSingleton(logger); + var childServiceCollection = new ServiceCollection() + .AddSingleton(serviceProvider) + .AddSingleton(logger) + .AddSingleton(typeof(IStartupLogger<>), typeof(NestedStartupLogger<>)) + .AddSingleton(logger.Topic!); foreach (ServiceDescriptor service in serviceProvider.GetRequiredService()) { @@ -78,4 +81,11 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type"); } } + + private class NestedStartupLogger : StartupLogger, IStartupLogger + { + public NestedStartupLogger(ILogger logger, StartupLogTopic topic) : base(logger, topic) + { + } + } } diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 0b77d63ac7..dc7fa5eb36 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -60,7 +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 IStartupLogger? _migrationLogger; private static string? _restoreFromBackup; /// @@ -103,6 +103,7 @@ namespace Jellyfin.Server _setupServer = new SetupServer(static () => _jellyfinHost?.Services?.GetService(), appPaths, static () => _appHost, _loggerFactory, startupConfig); await _setupServer.RunAsync().ConfigureAwait(false); _logger = _loggerFactory.CreateLogger("Main"); + StartupLogger.Logger = new StartupLogger(_logger); // Use the logging framework for uncaught exceptions instead of std error AppDomain.CurrentDomain.UnhandledException += (_, e) @@ -178,7 +179,9 @@ namespace Jellyfin.Server }) .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig)) .UseSerilog() - .ConfigureServices(e => e.AddTransient().AddSingleton(e)) + .ConfigureServices(e => e + .RegisterStartupLogger() + .AddSingleton(e)) .Build(); // Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection. @@ -268,7 +271,7 @@ namespace Jellyfin.Server /// A task. public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig) { - _migrationLogger = StartupLogger.Logger.BeginGroup($"Migration Service"); + _migrationLogger = StartupLogger.Logger.BeginGroup($"Migration Service"); var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer()); startupConfigurationManager.AddParts([new DatabaseConfigurationFactory()]); var migrationStartupServiceProvider = new ServiceCollection() @@ -276,7 +279,7 @@ namespace Jellyfin.Server .AddJellyfinDbContext(startupConfigurationManager, startupConfig) .AddSingleton(appPaths) .AddSingleton(appPaths) - .AddSingleton(_migrationLogger); + .RegisterStartupLogger(); migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider); var startupService = migrationStartupServiceProvider.BuildServiceProvider(); diff --git a/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs index 2c2ef05f8a..e7c1939368 100644 --- a/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs +++ b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs @@ -1,5 +1,4 @@ using System; -using Morestachio.Helper.Logging; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Jellyfin.Server.ServerSetupApp; @@ -9,6 +8,11 @@ namespace Jellyfin.Server.ServerSetupApp; /// public interface IStartupLogger : ILogger { + /// + /// Gets the topic this logger is assigned to. + /// + StartupLogTopic? Topic { get; } + /// /// Adds another logger instance to this logger for combined logging. /// @@ -22,4 +26,41 @@ public interface IStartupLogger : ILogger /// Defines the log message that introduces the new group. /// A new logger that can write to the group. IStartupLogger BeginGroup(FormattableString logEntry); + + /// + /// Adds another logger instance to this logger for combined logging. + /// + /// Other logger to rely messages to. + /// A combined logger. + /// The logger cateogry. + 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. + /// The logger cateogry. + IStartupLogger BeginGroup(FormattableString logEntry); +} + +/// +/// Defines a logger that can be injected via DI to get a startup logger initialised with an logger framework connected . +/// +/// The logger cateogry. +public interface IStartupLogger : IStartupLogger +{ + /// + /// Adds another logger instance to this logger for combined logging. + /// + /// Other logger to rely messages to. + /// A combined logger. + new 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. + new IStartupLogger BeginGroup(FormattableString logEntry); } diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index d88dbee577..6d58e3c4e1 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -71,7 +71,7 @@ public sealed class SetupServer : IDisposable _configurationManager.RegisterConfiguration(); } - internal static ConcurrentQueue? LogQueue { get; set; } = new(); + internal static ConcurrentQueue? LogQueue { get; set; } = new(); /// /// Gets a value indicating whether Startup server is currently running. @@ -88,12 +88,12 @@ public sealed class SetupServer : IDisposable _startupUiRenderer = (await ParserOptionsBuilder.New() .WithTemplate(fileTemplate) .WithFormatter( - (StartupLogEntry logEntry, IEnumerable children) => + (StartupLogTopic logEntry, IEnumerable children) => { if (children.Any()) { var maxLevel = logEntry.LogLevel; - var stack = new Stack(children); + var stack = new Stack(children); while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) != null) // error is the highest inherted error level. { @@ -362,15 +362,4 @@ public sealed class SetupServer : IDisposable }); } } - - 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/StartupLogTopic.cs b/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs new file mode 100644 index 0000000000..cd440a9b53 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.ServerSetupApp; + +/// +/// Defines a topic for the Startup UI. +/// +public class StartupLogTopic +{ + /// + /// Gets or Sets the LogLevel. + /// + public LogLevel LogLevel { get; set; } + + /// + /// Gets or Sets the descriptor for the topic. + /// + public string? Content { get; set; } + + /// + /// Gets or sets the time the topic was created. + /// + public DateTimeOffset DateOfCreation { get; set; } + + /// + /// Gets the child items of this topic. + /// + public Collection Children { get; } = []; +} diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs index 2b86dc0c1a..0121854ce3 100644 --- a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs +++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs @@ -1,56 +1,86 @@ using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using Jellyfin.Server.Migrations.Routines; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Jellyfin.Server.ServerSetupApp; /// public class StartupLogger : IStartupLogger { - private readonly SetupServer.StartupLogEntry? _groupEntry; + private readonly StartupLogTopic? _topic; /// /// Initializes a new instance of the class. /// - public StartupLogger() + /// The underlying base logger. + public StartupLogger(ILogger logger) { - Loggers = []; + BaseLogger = logger; } /// /// Initializes a new instance of the class. /// - private StartupLogger(SetupServer.StartupLogEntry? groupEntry) : this() + /// The underlying base logger. + /// The group for this logger. + internal StartupLogger(ILogger logger, StartupLogTopic? topic) : this(logger) { - _groupEntry = groupEntry; + _topic = topic; } - internal static IStartupLogger Logger { get; } = new StartupLogger(); + internal static IStartupLogger Logger { get; set; } = new StartupLogger(NullLogger.Instance); - private List Loggers { get; set; } + /// + public StartupLogTopic? Topic => _topic; + + /// + /// Gets or Sets the underlying base logger. + /// + protected ILogger BaseLogger { get; set; } /// public IStartupLogger BeginGroup(FormattableString logEntry) { - var startupEntry = new SetupServer.StartupLogEntry() + return new StartupLogger(BaseLogger, AddToTopic(logEntry)); + } + + /// + public IStartupLogger With(ILogger logger) + { + return new StartupLogger(logger, Topic); + } + + /// + public IStartupLogger With(ILogger logger) + { + return new StartupLogger(logger, Topic); + } + + /// + public IStartupLogger BeginGroup(FormattableString logEntry) + { + return new StartupLogger(BaseLogger, AddToTopic(logEntry)); + } + + private StartupLogTopic AddToTopic(FormattableString logEntry) + { + var startupEntry = new StartupLogTopic() { Content = logEntry.ToString(CultureInfo.InvariantCulture), DateOfCreation = DateTimeOffset.Now }; - if (_groupEntry is null) + if (Topic is null) { SetupServer.LogQueue?.Enqueue(startupEntry); } else { - _groupEntry.Children.Add(startupEntry); + Topic.Children.Add(startupEntry); } - return new StartupLogger(startupEntry); + return startupEntry; } /// @@ -69,34 +99,26 @@ public class StartupLogger : IStartupLogger /// public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { - foreach (var item in Loggers.Where(e => e.IsEnabled(logLevel))) + if (BaseLogger.IsEnabled(logLevel)) { - item.Log(logLevel, eventId, state, exception, formatter); + // if enabled allow the base logger also to receive the message + BaseLogger.Log(logLevel, eventId, state, exception, formatter); } - var startupEntry = new SetupServer.StartupLogEntry() + var startupEntry = new StartupLogTopic() { LogLevel = logLevel, Content = formatter(state, exception), DateOfCreation = DateTimeOffset.Now }; - if (_groupEntry is null) + if (Topic is null) { SetupServer.LogQueue?.Enqueue(startupEntry); } else { - _groupEntry.Children.Add(startupEntry); + Topic.Children.Add(startupEntry); } } - - /// - public IStartupLogger With(ILogger logger) - { - return new StartupLogger(_groupEntry) - { - Loggers = [.. Loggers, logger] - }; - } } diff --git a/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs b/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs new file mode 100644 index 0000000000..ada4b56a7e --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs @@ -0,0 +1,18 @@ +using System; +using System.Globalization; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Jellyfin.Server.ServerSetupApp; + +internal static class StartupLoggerExtensions +{ + public static IServiceCollection RegisterStartupLogger(this IServiceCollection services) + { + return services + .AddTransient>() + .AddTransient(typeof(IStartupLogger<>), typeof(StartupLogger<>)); + } +} diff --git a/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs b/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs new file mode 100644 index 0000000000..64da0ce88a --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs @@ -0,0 +1,56 @@ +using System; +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.ServerSetupApp; + +/// +/// Startup logger for usage with DI that utilises an underlying logger from the DI. +/// +/// The category of the underlying logger. +#pragma warning disable SA1649 // File name should match first type name +public class StartupLogger : StartupLogger, IStartupLogger +#pragma warning restore SA1649 // File name should match first type name +{ + /// + /// Initializes a new instance of the class. + /// + /// The injected base logger. + public StartupLogger(ILogger logger) : base(logger) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The underlying base logger. + /// The group for this logger. + internal StartupLogger(ILogger logger, StartupLogTopic? groupEntry) : base(logger, groupEntry) + { + } + + IStartupLogger IStartupLogger.BeginGroup(FormattableString logEntry) + { + var startupEntry = new StartupLogTopic() + { + Content = logEntry.ToString(CultureInfo.InvariantCulture), + DateOfCreation = DateTimeOffset.Now + }; + + if (Topic is null) + { + SetupServer.LogQueue?.Enqueue(startupEntry); + } + else + { + Topic.Children.Add(startupEntry); + } + + return new StartupLogger(BaseLogger, startupEntry); + } + + IStartupLogger IStartupLogger.With(ILogger logger) + { + return new StartupLogger(logger, Topic); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index 725e359d7d..0952fb8b63 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -98,7 +98,10 @@ namespace Jellyfin.Server.Integration.Tests .AddEnvironmentVariables("JELLYFIN_") .AddInMemoryCollection(commandLineOpts.ConvertToConfig()); }) - .ConfigureServices(e => e.AddSingleton().AddSingleton(e)); + .ConfigureServices(e => e + .AddSingleton>() + .AddTransient(typeof(IStartupLogger<>), typeof(NullStartupLogger<>)) + .AddSingleton(e)); } /// @@ -132,13 +135,20 @@ namespace Jellyfin.Server.Integration.Tests base.Dispose(disposing); } - private sealed class NullStartupLogger : IStartupLogger + private sealed class NullStartupLogger : IStartupLogger { + public StartupLogTopic? Topic => throw new NotImplementedException(); + public IStartupLogger BeginGroup(FormattableString logEntry) { return this; } + public IStartupLogger BeginGroup(FormattableString logEntry) + { + return new NullStartupLogger(); + } + public IDisposable? BeginScope(TState state) where TState : notnull { @@ -160,10 +170,25 @@ namespace Jellyfin.Server.Integration.Tests return this; } + public IStartupLogger With(Microsoft.Extensions.Logging.ILogger logger) + { + return new NullStartupLogger(); + } + + IStartupLogger IStartupLogger.BeginGroup(FormattableString logEntry) + { + return new NullStartupLogger(); + } + IStartupLogger IStartupLogger.With(Microsoft.Extensions.Logging.ILogger logger) { return this; } + + IStartupLogger IStartupLogger.With(Microsoft.Extensions.Logging.ILogger logger) + { + return this; + } } } }