// // Copyright (c) PlaceholderCompany. All rights reserved. // namespace Jellyfin.Server.Helpers; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; 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; /// /// A class containing helper methods for server startup. /// public static class StartupHelpers { private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" }; /// /// Logs relevant environment variables and information about the host. /// /// The logger to use. /// The application paths to use. public static void LogEnvironmentInfo(ILogger logger, IApplicationPaths appPaths) { // Distinct these to prevent users from reporting problems that aren't actually problems var commandLineArgs = Environment .GetCommandLineArgs() .Distinct(); // Get all relevant environment variables var allEnvVars = Environment.GetEnvironmentVariables(); var relevantEnvVars = new Dictionary(); foreach (var key in allEnvVars.Keys) { if (_relevantEnvVarPrefixes.Any(prefix => key.ToString()!.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) { relevantEnvVars.Add(key, allEnvVars[key]!); } } logger.LogInformation("Environment Variables: {EnvVars}", relevantEnvVars); logger.LogInformation("Arguments: {Args}", commandLineArgs); logger.LogInformation("Operating system: {OS}", RuntimeInformation.OSDescription); logger.LogInformation("Architecture: {Architecture}", RuntimeInformation.OSArchitecture); logger.LogInformation("64-Bit Process: {Is64Bit}", Environment.Is64BitProcess); logger.LogInformation("User Interactive: {IsUserInteractive}", Environment.UserInteractive); logger.LogInformation("Processor count: {ProcessorCount}", Environment.ProcessorCount); logger.LogInformation("Program data path: {ProgramDataPath}", appPaths.ProgramDataPath); logger.LogInformation("Log directory path: {LogDirectoryPath}", appPaths.LogDirectoryPath); logger.LogInformation("Config directory path: {ConfigurationDirectoryPath}", appPaths.ConfigurationDirectoryPath); logger.LogInformation("Cache path: {CachePath}", appPaths.CachePath); logger.LogInformation("Temp directory path: {TempDirPath}", appPaths.TempDirectory); logger.LogInformation("Web resources path: {WebPath}", appPaths.WebPath); logger.LogInformation("Application directory: {ApplicationPath}", appPaths.ProgramSystemPath); } /// /// Loads startup configuration from startup.json file if it exists. /// If no configuration file exists, creates a default one in the application directory. /// /// Configuration root if file exists, null otherwise. private static IConfigurationRoot? LoadStartupConfiguration() { const string ConfigFileName = "startup.json"; // Search locations in priority order var searchPaths = new[] { Path.Combine(Directory.GetCurrentDirectory(), ConfigFileName), Path.Combine(AppContext.BaseDirectory, ConfigFileName), Path.Combine(AppContext.BaseDirectory, "config", ConfigFileName) }; foreach (var configPath in searchPaths) { if (File.Exists(configPath)) { try { var config = new ConfigurationBuilder() .AddJsonFile(configPath, optional: false, reloadOnChange: false) .Build(); Console.WriteLine($"Loaded startup configuration from: {configPath}"); return config; } catch (Exception ex) { Console.WriteLine($"Warning: Failed to load startup configuration from {configPath}: {ex.Message}"); } } } // No configuration file found - create a default one CreateDefaultStartupConfiguration(); return null; } /// /// Creates a default startup.json configuration file with OS-specific default paths. /// private static void CreateDefaultStartupConfiguration() { const string ConfigFileName = "startup.json"; // Determine the best location for the configuration file // Priority: Current directory (for portable installations) > AppContext.BaseDirectory var configPath = Path.Combine(Directory.GetCurrentDirectory(), ConfigFileName); try { // Determine OS-specific default paths string dataDir, configDir, cacheDir, logDir, tempDir, webDir; string osComment; if (OperatingSystem.IsWindows()) { // Windows defaults - use ProgramData dataDir = "C:/ProgramData/jellyfin"; configDir = "C:/ProgramData/jellyfin"; cacheDir = "C:/ProgramData/jellyfin/cache"; logDir = "C:/ProgramData/jellyfin/log"; tempDir = Path.Combine(Path.GetTempPath(), "jellyfin"); webDir = "C:/ProgramData/jellyfin/wwwroot"; osComment = "Windows defaults - using C:/ProgramData/jellyfin"; } else if (OperatingSystem.IsLinux()) { // Linux defaults - use FHS standard paths dataDir = "/var/lib/jellyfin"; configDir = "/etc/jellyfin"; cacheDir = "/var/cache/jellyfin"; logDir = "/var/log/jellyfin"; tempDir = "/var/tmp/jellyfin"; webDir = "/usr/share/jellyfin/wwwroot"; osComment = "Linux defaults - following Filesystem Hierarchy Standard (FHS)"; } else if (OperatingSystem.IsMacOS()) { // macOS defaults - use user Library var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); dataDir = Path.Combine(homeDir, "Library", "Application Support", "jellyfin"); configDir = Path.Combine(homeDir, "Library", "Application Support", "jellyfin", "config"); cacheDir = Path.Combine(homeDir, "Library", "Caches", "jellyfin"); logDir = Path.Combine(homeDir, "Library", "Logs", "jellyfin"); tempDir = Path.Combine(Path.GetTempPath(), "jellyfin"); webDir = Path.Combine(homeDir, "Library", "Application Support", "jellyfin", "wwwroot"); osComment = "macOS defaults - using user Library paths"; } else { // Unknown OS - use portable defaults dataDir = "./data"; configDir = "./config"; cacheDir = "./cache"; logDir = "./logs"; tempDir = "./temp"; webDir = "./wwwroot"; osComment = "Portable defaults - using relative paths"; } var defaultConfig = new { _comment = $"Jellyfin Startup Configuration - {osComment}", _note = "These paths will be used unless overridden by environment variables or command-line arguments", _priority = "Command-line args > Environment variables > This file > Built-in defaults", _examples = "See startup.linux.json or startup.windows.json in Resources/Configuration for other OS examples", Paths = new { DataDir = dataDir, ConfigDir = configDir, CacheDir = cacheDir, LogDir = logDir, TempDir = tempDir, WebDir = webDir } }; var json = System.Text.Json.JsonSerializer.Serialize(defaultConfig, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(configPath, json); Console.WriteLine($"Created default startup configuration at: {configPath}"); Console.WriteLine($"Using {osComment}"); Console.WriteLine("You can customize this file to set different paths for Jellyfin."); } catch (Exception ex) { Console.WriteLine($"Warning: Could not create default startup configuration: {ex.Message}"); } } /// /// 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. /// . public static ServerApplicationPaths CreateApplicationPaths(StartupOptions options) { // Try to load startup configuration from file var startupConfig = LoadStartupConfiguration(); // LocalApplicationData // Windows: %LocalAppData% // macOS: NSApplicationSupportDirectory // UNIX: $XDG_DATA_HOME var dataDir = options.DataDir ?? Environment.GetEnvironmentVariable("JELLYFIN_DATA_DIR") ?? startupConfig?.GetValue("Paths:DataDir") ?? Path.Join( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.DoNotVerify), "jellyfin"); var configDir = options.ConfigDir ?? Environment.GetEnvironmentVariable("JELLYFIN_CONFIG_DIR") ?? startupConfig?.GetValue("Paths:ConfigDir"); if (configDir is null) { configDir = Path.Join(dataDir, "config"); if (options.DataDir is null && !Directory.Exists(configDir) && !OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS()) { // UNIX: $XDG_CONFIG_HOME configDir = Path.Join( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData, Environment.SpecialFolderOption.DoNotVerify), "jellyfin"); } } var cacheDir = options.CacheDir ?? Environment.GetEnvironmentVariable("JELLYFIN_CACHE_DIR") ?? startupConfig?.GetValue("Paths:CacheDir"); if (cacheDir is null) { if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) { cacheDir = Path.Join(dataDir, "cache"); } else { cacheDir = Path.Join(GetXdgCacheHome(), "jellyfin"); } } var webDir = options.WebDir ?? Environment.GetEnvironmentVariable("JELLYFIN_WEB_DIR") ?? startupConfig?.GetValue("Paths:WebDir"); if (webDir is null) { webDir = Path.Join(AppContext.BaseDirectory, "wwwroot"); } var logDir = options.LogDir ?? Environment.GetEnvironmentVariable("JELLYFIN_LOG_DIR") ?? startupConfig?.GetValue("Paths:LogDir"); if (logDir is null) { logDir = Path.Join(dataDir, "log"); } var tempDir = options.TempDir ?? Environment.GetEnvironmentVariable("JELLYFIN_TEMP_DIR") ?? startupConfig?.GetValue("Paths:TempDir"); if (tempDir is null) { tempDir = Path.Join(Path.GetTempPath(), "jellyfin"); } // Normalize paths. Only possible with GetFullPath for now - https://github.com/dotnet/runtime/issues/2162 dataDir = Path.GetFullPath(dataDir); logDir = Path.GetFullPath(logDir); configDir = Path.GetFullPath(configDir); cacheDir = Path.GetFullPath(cacheDir); webDir = Path.GetFullPath(webDir); tempDir = Path.GetFullPath(tempDir); // Ensure the main folders exist before we continue try { Directory.CreateDirectory(dataDir); Directory.CreateDirectory(logDir); Directory.CreateDirectory(configDir); Directory.CreateDirectory(cacheDir); Directory.CreateDirectory(tempDir); } 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, tempDir); } private static string GetXdgCacheHome() { // $XDG_CACHE_HOME defines the base directory relative to which // user specific non-essential data files should be stored. var cacheHome = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); // If $XDG_CACHE_HOME is either not set or a relative path, // a default equal to $HOME/.cache should be used. if (cacheHome is null || !cacheHome.StartsWith("/", StringComparison.Ordinal)) { cacheHome = Path.Join( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify), ".cache"); } return cacheHome; } /// /// Gets the path for the unix socket Kestrel should bind to. /// /// The startup config. /// The application paths. /// The path for Kestrel to bind to. public static string GetUnixSocketPath(IConfiguration startupConfig, IApplicationPaths appPaths) { var socketPath = startupConfig.GetUnixSocketPath(); if (string.IsNullOrEmpty(socketPath)) { const string SocketFile = "jellyfin.sock"; var xdgRuntimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); if (xdgRuntimeDir is null) { // Fall back to config dir socketPath = Path.Join(appPaths.ConfigurationDirectoryPath, SocketFile); } else { socketPath = Path.Join(xdgRuntimeDir, SocketFile); } } return socketPath; } /// /// Sets the unix file permissions for Kestrel's socket file. /// /// The startup config. /// The socket path. /// The logger. [UnsupportedOSPlatform("windows")] public static void SetUnixSocketPermissions(IConfiguration startupConfig, string socketPath, ILogger logger) { var socketPerms = startupConfig.GetUnixSocketPermissions(); if (!string.IsNullOrEmpty(socketPerms)) { File.SetUnixFileMode(socketPath, (UnixFileMode)Convert.ToInt32(socketPerms, 8)); logger.LogInformation("Kestrel unix socket permissions set to {SocketPerms}", socketPerms); } } /// /// Initialize the logging configuration file using the bundled resource file as a default if it doesn't exist /// already. /// /// The application paths. /// A task representing the creation of the configuration file, or a completed task if the file already exists. public static async Task InitLoggingConfigFile(IApplicationPaths appPaths) { // Do nothing if the config file already exists string configPath = Path.Combine(appPaths.ConfigurationDirectoryPath, Program.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"; Stream resource = typeof(Program).Assembly.GetManifestResourceStream(ResourcePath) ?? throw new InvalidOperationException($"Invalid resource path: '{ResourcePath}'"); await using (resource.ConfigureAwait(false)) { Stream dst = new FileStream(configPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); await using (dst.ConfigureAwait(false)) { // Copy the resource contents to the expected file path for the config file await resource.CopyToAsync(dst).ConfigureAwait(false); } } } /// /// Initialize Serilog using configuration and fall back to defaults on failure. /// /// The configuration object. /// The application paths. public static void InitializeLoggingFramework(IConfiguration configuration, IApplicationPaths appPaths) { 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) { Log.Logger = new LoggerConfiguration() .WriteTo.Console( outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}", formatProvider: CultureInfo.InvariantCulture) .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}", formatProvider: CultureInfo.InvariantCulture, encoding: Encoding.UTF8)) .Enrich.FromLogContext() .Enrich.WithThreadId() .CreateLogger(); Log.Logger.Fatal(ex, "Failed to create/read logger configuration"); } } /// /// Call static initialization methods for the application. /// public static void PerformStaticInitialization() { // 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); } }