using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using CommandLine; using Emby.Drawing; using Emby.Server.Implementations; using Emby.Server.Implementations.HttpServer; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Networking; using Jellyfin.Drawing.Skia; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Extensions; using MediaBrowser.WebDashboard.Api; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Serilog; using Serilog.Extensions.Logging; using SQLitePCL; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Jellyfin.Server { /// /// Class containing the entry point of the application. /// public static class Program { /// /// The name of logging configuration file containing application defaults. /// public static readonly string LoggingConfigFileDefault = "logging.default.json"; /// /// The name of the logging configuration file containing the system-specific override settings. /// public static readonly string LoggingConfigFileSystem = "logging.json"; private static readonly CancellationTokenSource _tokenSource = new CancellationTokenSource(); private static readonly ILoggerFactory _loggerFactory = new SerilogLoggerFactory(); private static ILogger _logger = NullLogger.Instance; private static bool _restartOnShutdown; /// /// The entry point of the application. /// /// The command line arguments passed. /// . public static Task Main(string[] args) { // For backwards compatibility. // Modify any input arguments now which start with single-hyphen to POSIX standard // double-hyphen to allow parsing by CommandLineParser package. const string Pattern = @"^(-[^-\s]{2})"; // Match -xx, not -x, not --xx, not xx const string Substitution = @"-$1"; // Prepend with additional single-hyphen var regex = new Regex(Pattern); for (var i = 0; i < args.Length; i++) { args[i] = regex.Replace(args[i], Substitution); } // Parse the command line arguments and either start the app or exit indicating error return Parser.Default.ParseArguments(args) .MapResult(StartApp, _ => Task.CompletedTask); } /// /// Shuts down the application. /// internal static void Shutdown() { if (!_tokenSource.IsCancellationRequested) { _tokenSource.Cancel(); } } /// /// Restarts the application. /// internal static void Restart() { _restartOnShutdown = true; Shutdown(); } private static async Task StartApp(StartupOptions options) { var stopWatch = new Stopwatch(); stopWatch.Start(); // Log all uncaught exceptions to std error static void UnhandledExceptionToConsole(object sender, UnhandledExceptionEventArgs e) => Console.Error.WriteLine("Unhandled Exception\n" + e.ExceptionObject.ToString()); AppDomain.CurrentDomain.UnhandledException += UnhandledExceptionToConsole; ServerApplicationPaths appPaths = CreateApplicationPaths(options); // $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", appPaths.LogDirectoryPath); await InitLoggingConfigFile(appPaths).ConfigureAwait(false); // Create an instance of the application configuration to use for application startup IConfiguration startupConfig = CreateAppConfiguration(options, appPaths); // Initialize logging framework InitializeLoggingFramework(startupConfig, appPaths); _logger = _loggerFactory.CreateLogger("Main"); // Log uncaught exceptions to the logging instead of std error AppDomain.CurrentDomain.UnhandledException -= UnhandledExceptionToConsole; AppDomain.CurrentDomain.UnhandledException += (sender, e) => _logger.LogCritical((Exception)e.ExceptionObject, "Unhandled Exception"); // Intercept Ctrl+C and Ctrl+Break Console.CancelKeyPress += (sender, e) => { if (_tokenSource.IsCancellationRequested) { return; // Already shutting down } e.Cancel = true; _logger.LogInformation("Ctrl+C, shutting down"); Environment.ExitCode = 128 + 2; Shutdown(); }; // Register a SIGTERM handler AppDomain.CurrentDomain.ProcessExit += (sender, e) => { if (_tokenSource.IsCancellationRequested) { return; // Already shutting down } _logger.LogInformation("Received a SIGTERM signal, shutting down"); Environment.ExitCode = 128 + 15; Shutdown(); }; _logger.LogInformation( "Jellyfin version: {Version}", Assembly.GetEntryAssembly()!.GetName().Version!.ToString(3)); ApplicationHost.LogEnvironmentInfo(_logger, appPaths); // Make sure we have all the code pages we can get // Ref: https://docs.microsoft.com/en-us/dotnet/api/system.text.codepagesencodingprovider.instance?view=netcore-3.0#remarks Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); // Increase the max http request limit // The default connection limit is 10 for ASP.NET hosted applications and 2 for all others. ServicePointManager.DefaultConnectionLimit = Math.Max(96, ServicePointManager.DefaultConnectionLimit); // Disable the "Expect: 100-Continue" header by default // http://stackoverflow.com/questions/566437/http-post-returns-the-error-417-expectation-failed-c ServicePointManager.Expect100Continue = false; Batteries_V2.Init(); if (raw.sqlite3_enable_shared_cache(1) != raw.SQLITE_OK) { _logger.LogWarning("Failed to enable shared cache for SQLite"); } var appHost = new CoreAppHost( appPaths, _loggerFactory, options, new ManagedFileSystem(_loggerFactory.CreateLogger(), appPaths), GetImageEncoder(appPaths), new NetworkManager(_loggerFactory.CreateLogger())); try { // If hosting the web client, validate the client content path if (startupConfig.HostWebClient()) { string webContentPath = DashboardService.GetDashboardUIPath(startupConfig, appHost.ServerConfigurationManager); if (!Directory.Exists(webContentPath) || Directory.GetFiles(webContentPath).Length == 0) { throw new InvalidOperationException( "The server is expected to host the web client, but the provided content directory is either " + $"invalid or empty: {webContentPath}. If you do not want to host the web client with the " + "server, you may set the '--nowebclient' command line flag, or set" + $"'{MediaBrowser.Controller.Extensions.ConfigurationExtensions.HostWebClientKey}=false' in your config settings."); } } ServiceCollection serviceCollection = new ServiceCollection(); await appHost.InitAsync(serviceCollection, startupConfig).ConfigureAwait(false); var webHost = CreateWebHostBuilder(appHost, serviceCollection, options, startupConfig, appPaths).Build(); // Re-use the web host service provider in the app host since ASP.NET doesn't allow a custom service collection. appHost.ServiceProvider = webHost.Services; appHost.InitializeServices(); appHost.FindParts(); Migrations.MigrationRunner.Run(appHost, _loggerFactory); try { await webHost.StartAsync().ConfigureAwait(false); } catch { _logger.LogError("Kestrel failed to start! This is most likely due to an invalid address or port bind - correct your bind configuration in system.xml and try again."); throw; } await appHost.RunStartupTasksAsync().ConfigureAwait(false); stopWatch.Stop(); _logger.LogInformation("Startup complete {Time:g}", stopWatch.Elapsed); // Block main thread until shutdown await Task.Delay(-1, _tokenSource.Token).ConfigureAwait(false); } catch (TaskCanceledException) { // Don't throw on cancellation } catch (Exception ex) { _logger.LogCritical(ex, "Error while starting server."); } finally { appHost?.Dispose(); } if (_restartOnShutdown) { StartNewInstance(options); } } private static IWebHostBuilder CreateWebHostBuilder( ApplicationHost appHost, IServiceCollection serviceCollection, StartupOptions commandLineOpts, IConfiguration startupConfig, IApplicationPaths appPaths) { return new WebHostBuilder() .UseKestrel(options => { var addresses = appHost.ServerConfigurationManager .Configuration .LocalNetworkAddresses .Select(appHost.NormalizeConfiguredLocalAddress) .Where(i => i != null) .ToList(); if (addresses.Any()) { foreach (var address in addresses) { _logger.LogInformation("Kestrel listening on {IpAddress}", address); options.Listen(address, appHost.HttpPort); if (appHost.EnableHttps && appHost.Certificate != null) { options.Listen(address, appHost.HttpsPort, listenOptions => { listenOptions.UseHttps(appHost.Certificate); listenOptions.Protocols = HttpProtocols.Http1AndHttp2; }); } } } else { _logger.LogInformation("Kestrel listening on all interfaces"); options.ListenAnyIP(appHost.HttpPort); if (appHost.EnableHttps && appHost.Certificate != null) { options.ListenAnyIP(appHost.HttpsPort, listenOptions => { listenOptions.UseHttps(appHost.Certificate); listenOptions.Protocols = HttpProtocols.Http1AndHttp2; }); } } }) .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(commandLineOpts, appPaths, startupConfig)) .UseSerilog() .ConfigureServices(services => { // Merge the external ServiceCollection into ASP.NET DI services.TryAdd(serviceCollection); }) .UseStartup(); } /// /// Create the data, config and log paths from the variety of inputs(command line args, /// environment variables) or decide on what default to use. For Windows it's %AppPath% /// for everything else the /// XDG approach /// is followed. /// /// The for this instance. /// . private static ServerApplicationPaths CreateApplicationPaths(StartupOptions options) { // dataDir // IF --datadir // ELSE IF $JELLYFIN_DATA_DIR // ELSE IF windows, use <%APPDATA%>/jellyfin // ELSE IF $XDG_DATA_HOME then use $XDG_DATA_HOME/jellyfin // ELSE use $HOME/.local/share/jellyfin var dataDir = options.DataDir; if (string.IsNullOrEmpty(dataDir)) { dataDir = Environment.GetEnvironmentVariable("JELLYFIN_DATA_DIR"); if (string.IsNullOrEmpty(dataDir)) { // LocalApplicationData follows the XDG spec on unix machines dataDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "jellyfin"); } } // configDir // IF --configdir // ELSE IF $JELLYFIN_CONFIG_DIR // ELSE IF --datadir, use /config (assume portable run) // ELSE IF /config exists, use that // ELSE IF windows, use /config // ELSE IF $XDG_CONFIG_HOME use $XDG_CONFIG_HOME/jellyfin // ELSE $HOME/.config/jellyfin var configDir = options.ConfigDir; if (string.IsNullOrEmpty(configDir)) { configDir = Environment.GetEnvironmentVariable("JELLYFIN_CONFIG_DIR"); if (string.IsNullOrEmpty(configDir)) { if (options.DataDir != null || Directory.Exists(Path.Combine(dataDir, "config")) || RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // Hang config folder off already set dataDir configDir = Path.Combine(dataDir, "config"); } else { // $XDG_CONFIG_HOME defines the base directory relative to which // user specific configuration files should be stored. configDir = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); // If $XDG_CONFIG_HOME is either not set or empty, // a default equal to $HOME /.config should be used. if (string.IsNullOrEmpty(configDir)) { configDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); } configDir = Path.Combine(configDir, "jellyfin"); } } } // cacheDir // IF --cachedir // ELSE IF $JELLYFIN_CACHE_DIR // ELSE IF windows, use /cache // ELSE IF XDG_CACHE_HOME, use $XDG_CACHE_HOME/jellyfin // ELSE HOME/.cache/jellyfin var cacheDir = options.CacheDir; if (string.IsNullOrEmpty(cacheDir)) { cacheDir = Environment.GetEnvironmentVariable("JELLYFIN_CACHE_DIR"); if (string.IsNullOrEmpty(cacheDir)) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // Hang cache folder off already set dataDir cacheDir = Path.Combine(dataDir, "cache"); } else { // $XDG_CACHE_HOME defines the base directory relative to which // user specific non-essential data files should be stored. cacheDir = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); // If $XDG_CACHE_HOME is either not set or empty, // a default equal to $HOME/.cache should be used. if (string.IsNullOrEmpty(cacheDir)) { cacheDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache"); } cacheDir = Path.Combine(cacheDir, "jellyfin"); } } } // webDir // IF --webdir // ELSE IF $JELLYFIN_WEB_DIR // ELSE /jellyfin-web var webDir = options.WebDir; if (string.IsNullOrEmpty(webDir)) { webDir = Environment.GetEnvironmentVariable("JELLYFIN_WEB_DIR"); if (string.IsNullOrEmpty(webDir)) { // Use default location under ResourcesPath webDir = Path.Combine(AppContext.BaseDirectory, "jellyfin-web"); } } // logDir // IF --logdir // ELSE IF $JELLYFIN_LOG_DIR // ELSE IF --datadir, use /log (assume portable run) // ELSE /log var logDir = options.LogDir; if (string.IsNullOrEmpty(logDir)) { logDir = Environment.GetEnvironmentVariable("JELLYFIN_LOG_DIR"); if (string.IsNullOrEmpty(logDir)) { // Hang log folder off already set dataDir logDir = Path.Combine(dataDir, "log"); } } // Ensure the main folders exist before we continue try { Directory.CreateDirectory(dataDir); Directory.CreateDirectory(logDir); Directory.CreateDirectory(configDir); Directory.CreateDirectory(cacheDir); } catch (IOException ex) { Console.Error.WriteLine("Error whilst attempting to create folder"); Console.Error.WriteLine(ex.ToString()); Environment.Exit(1); } return new ServerApplicationPaths(dataDir, logDir, configDir, cacheDir, webDir); } /// /// Initialize the logging configuration file using the bundled resource file as a default if it doesn't exist /// already. /// private static async Task InitLoggingConfigFile(IApplicationPaths appPaths) { // Do nothing if the config file already exists string configPath = Path.Combine(appPaths.ConfigurationDirectoryPath, LoggingConfigFileDefault); if (File.Exists(configPath)) { return; } // Get a stream of the resource contents // NOTE: The .csproj name is used instead of the assembly name in the resource path const string ResourcePath = "Jellyfin.Server.Resources.Configuration.logging.json"; await using Stream? resource = typeof(Program).Assembly.GetManifestResourceStream(ResourcePath) ?? throw new InvalidOperationException($"Invalid resource path: '{ResourcePath}'"); // Copy the resource contents to the expected file path for the config file await using Stream dst = File.Open(configPath, FileMode.CreateNew); await resource.CopyToAsync(dst).ConfigureAwait(false); } private static IConfiguration CreateAppConfiguration(StartupOptions commandLineOpts, IApplicationPaths appPaths) { return new ConfigurationBuilder() .ConfigureAppConfiguration(commandLineOpts, appPaths) .Build(); } private static IConfigurationBuilder ConfigureAppConfiguration( this IConfigurationBuilder config, StartupOptions commandLineOpts, IApplicationPaths appPaths, IConfiguration? startupConfig = null) { // Use the swagger API page as the default redirect path if not hosting the web client var inMemoryDefaultConfig = ConfigurationOptions.DefaultConfiguration; if (startupConfig != null && !startupConfig.HostWebClient()) { inMemoryDefaultConfig[HttpListenerHost.DefaultRedirectKey] = "swagger/index.html"; } return config .SetBasePath(appPaths.ConfigurationDirectoryPath) .AddInMemoryCollection(inMemoryDefaultConfig) .AddJsonFile(LoggingConfigFileDefault, optional: false, reloadOnChange: true) .AddJsonFile(LoggingConfigFileSystem, optional: true, reloadOnChange: true) .AddEnvironmentVariables("JELLYFIN_") .AddInMemoryCollection(commandLineOpts.ConvertToConfig()); } /// /// Initialize Serilog using configuration and fall back to defaults on failure. /// private static void InitializeLoggingFramework(IConfiguration configuration, IApplicationPaths appPaths) { try { // Serilog.Log is used by SerilogLoggerFactory when no logger is specified Serilog.Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(configuration) .Enrich.FromLogContext() .Enrich.WithThreadId() .CreateLogger(); } catch (Exception ex) { Serilog.Log.Logger = new LoggerConfiguration() .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}") .WriteTo.Async(x => x.File( Path.Combine(appPaths.LogDirectoryPath, "log_.log"), rollingInterval: RollingInterval.Day, outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}")) .Enrich.FromLogContext() .Enrich.WithThreadId() .CreateLogger(); Serilog.Log.Logger.Fatal(ex, "Failed to create/read logger configuration"); } } private static IImageEncoder GetImageEncoder(IApplicationPaths appPaths) { try { // Test if the native lib is available SkiaEncoder.TestSkia(); return new SkiaEncoder( _loggerFactory.CreateLogger(), appPaths); } catch (Exception ex) { _logger.LogWarning(ex, $"Skia not available. Will fallback to {nameof(NullImageEncoder)}."); } return new NullImageEncoder(); } private static void StartNewInstance(StartupOptions options) { _logger.LogInformation("Starting new instance"); var module = options.RestartPath; if (string.IsNullOrWhiteSpace(module)) { module = Environment.GetCommandLineArgs()[0]; } string commandLineArgsString; if (options.RestartArgs != null) { commandLineArgsString = options.RestartArgs ?? string.Empty; } else { commandLineArgsString = string.Join( ' ', Environment.GetCommandLineArgs().Skip(1).Select(NormalizeCommandLineArgument)); } _logger.LogInformation("Executable: {0}", module); _logger.LogInformation("Arguments: {0}", commandLineArgsString); Process.Start(module, commandLineArgsString); } private static string NormalizeCommandLineArgument(string arg) { if (!arg.Contains(" ", StringComparison.OrdinalIgnoreCase)) { return arg; } return "\"" + arg + "\""; } } }