// // Copyright (c) PlaceholderCompany. All rights reserved. // #pragma warning disable SA1201 // Elements should appear in the correct order namespace Jellyfin.Database.Providers.Postgres; using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.DbConfiguration; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Entities.Security; using Jellyfin.Database.Providers.Postgres.Exceptions; using MediaBrowser.Common.Configuration; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Npgsql; /// /// Configures Jellyfin to use a PostgreSQL database. /// [JellyfinDatabaseProviderKey("Jellyfin-PostgreSQL")] public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider { private readonly IApplicationPaths applicationPaths; private readonly ILogger logger; private readonly IConfigurationManager? configurationManager; private PostgresBackupService? backupService; private string? currentHost; private DatabaseConfigurationOptions? databaseConfig; /// /// Initializes a new instance of the class. /// /// Service to construct the fallback when the old data path configuration is used. /// A logger. /// The configuration manager (optional, for backup support). public PostgresDatabaseProvider( IApplicationPaths applicationPaths, ILogger logger, IConfigurationManager? configurationManager = null) { this.applicationPaths = applicationPaths; this.logger = logger; this.configurationManager = configurationManager; } /// /// Schema names mapping to legacy database files. /// public static class Schemas { /// /// Schema for activity log tables (legacy: activitylog.db). /// public const string ActivityLog = "activitylog"; /// /// Schema for authentication tables (legacy: authentication.db). /// public const string Authentication = "authentication"; /// /// Schema for display preferences tables (legacy: displaypreferences.db). /// public const string DisplayPreferences = "displaypreferences"; /// /// Schema for library tables (legacy: library.db). /// public const string Library = "library"; /// /// Schema for user tables (legacy: users.db). /// public const string Users = "users"; /// /// Gets all schema names. /// public static string[] All => [ActivityLog, Authentication, DisplayPreferences, Library, Users]; } /// public IDbContextFactory? DbContextFactory { get; set; } /// public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration) { // Store the configuration for later use databaseConfig = databaseConfiguration; static T? GetOption(ICollection? options, string key, Func converter, T? defaultValue = default) { if (options is null) { return defaultValue; } var value = options.FirstOrDefault(e => e.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); if (value is null) { return defaultValue; } return converter(value.Value); } var customOptions = databaseConfiguration.CustomProviderOptions?.Options; // Start with connection string if provided, then override with individual options NpgsqlConnectionStringBuilder connectionBuilder; if (!string.IsNullOrWhiteSpace(databaseConfiguration.CustomProviderOptions?.ConnectionString)) { // Use the provided connection string as base connectionBuilder = new NpgsqlConnectionStringBuilder(databaseConfiguration.CustomProviderOptions.ConnectionString); logger.LogDebug("Using ConnectionString from configuration as base"); } else { // Start with defaults connectionBuilder = new NpgsqlConnectionStringBuilder { Host = "localhost", Port = 5432, Database = "jellyfin", Username = "jellyfin", Password = string.Empty, Pooling = true, CommandTimeout = 120, Timeout = 15, Multiplexing = false, MaxPoolSize = 100, MinPoolSize = 0 }; logger.LogDebug("Using default connection parameters"); } // Override with individual options if provided (these take precedence over ConnectionString) if (customOptions is not null && customOptions.Count > 0) { var host = GetOption(customOptions, "host", e => e, null); if (host is not null) { connectionBuilder.Host = host; } var port = GetOption(customOptions, "port", e => int.Parse(e), null); if (port.HasValue) { connectionBuilder.Port = port.Value; } var database = GetOption(customOptions, "database", e => e, null); if (database is not null) { connectionBuilder.Database = database; } var username = GetOption(customOptions, "username", e => e, null); if (username is not null) { connectionBuilder.Username = username; } var password = GetOption(customOptions, "password", e => e, null); if (password is not null) { connectionBuilder.Password = password; } var pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), null); if (pooling.HasValue) { connectionBuilder.Pooling = pooling.Value; } var commandTimeout = GetOption(customOptions, "command-timeout", e => int.Parse(e), null); if (commandTimeout.HasValue) { connectionBuilder.CommandTimeout = commandTimeout.Value; } var timeout = GetOption(customOptions, "connection-timeout", e => int.Parse(e), null); if (timeout.HasValue) { connectionBuilder.Timeout = timeout.Value; } var multiplexing = GetOption(customOptions, "multiplexing", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), null); if (multiplexing.HasValue) { connectionBuilder.Multiplexing = multiplexing.Value; } var maxPoolSize = GetOption(customOptions, "max-pool-size", e => int.Parse(e), null); if (maxPoolSize.HasValue) { connectionBuilder.MaxPoolSize = maxPoolSize.Value; } var minPoolSize = GetOption(customOptions, "min-pool-size", e => int.Parse(e), null); if (minPoolSize.HasValue) { connectionBuilder.MinPoolSize = minPoolSize.Value; } logger.LogDebug("Applied {Count} individual option overrides", customOptions.Count); } var connectionString = connectionBuilder.ToString(); // Store the host for backup operations currentHost = connectionBuilder.Host; // Log PostgreSQL connection parameters (without password) logger.LogInformation( "PostgreSQL connection: Host={Host}, Port={Port}, Database={Database}, Username={Username}, Pooling={Pooling}, MaxPoolSize={MaxPoolSize}, Multiplexing={Multiplexing}", connectionBuilder.Host, connectionBuilder.Port, connectionBuilder.Database, connectionBuilder.Username, connectionBuilder.Pooling, connectionBuilder.MaxPoolSize, connectionBuilder.Multiplexing); // Initialize backup service if configuration manager is available // Backup works for both local and remote servers via pg_dump/pg_restore client tools if (configurationManager is not null) { // Check if backups are explicitly disabled var disableBackups = GetOption(customOptions, "disable-backups", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), null) ?? false; if (disableBackups) { logger.LogInformation("PostgreSQL backup service is disabled (disable-backups=true in configuration)"); backupService = null; } else { var loggerFactory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; var backupLogger = loggerFactory.CreateLogger(); backupService = new PostgresBackupService(backupLogger, configurationManager); if (IsLocalHost(currentHost)) { logger.LogInformation("PostgreSQL backup service initialized for local database at {Host} (using pg_dump/pg_restore tools)", currentHost); } else { logger.LogInformation("PostgreSQL backup service initialized for remote database at {Host} (using pg_dump/pg_restore tools)", currentHost); logger.LogWarning("Note: Ensure pg_dump and pg_restore client tools are installed locally and network connectivity to {Host} is available", currentHost); } logger.LogInformation("PostgreSQL client binaries (pg_dump/pg_restore) must be in system PATH or specify full paths in BackupOptions"); } } else { logger.LogWarning("Configuration manager not available - PostgreSQL backup service disabled"); } options .UseNpgsql( connectionString, npgsqlOptions => { npgsqlOptions.MigrationsAssembly(GetType().Assembly.FullName); npgsqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); }) .ConfigureWarnings(warnings => warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); var enableSensitiveDataLogging = GetOption(customOptions, "EnableSensitiveDataLogging", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), null) ?? false; if (enableSensitiveDataLogging) { options.EnableSensitiveDataLogging(enableSensitiveDataLogging); logger.LogInformation("EnableSensitiveDataLogging is enabled on PostgreSQL connection"); } } /// /// Ensures the PostgreSQL database exists, creating it if necessary. /// /// The database configuration. /// Cancellation token. /// A task representing the asynchronous operation. public async Task EnsureDatabaseExistsAsync(DatabaseConfigurationOptions databaseConfiguration, CancellationToken cancellationToken = default) { var customOptions = databaseConfiguration.CustomProviderOptions?.Options; // Start with connection string if provided, then override with individual options NpgsqlConnectionStringBuilder connectionBuilder; if (!string.IsNullOrWhiteSpace(databaseConfiguration.CustomProviderOptions?.ConnectionString)) { // Parse the existing connection string to get all parameters connectionBuilder = new NpgsqlConnectionStringBuilder(databaseConfiguration.CustomProviderOptions.ConnectionString); logger.LogDebug("EnsureDatabaseExists: Using ConnectionString from configuration as base"); } else { // Start with defaults connectionBuilder = new NpgsqlConnectionStringBuilder { Host = "localhost", Port = 5432, Database = "jellyfin", Username = "jellyfin", Password = string.Empty, Timeout = 15 }; logger.LogDebug("EnsureDatabaseExists: Using default connection parameters"); } // Override with individual options if provided (these take precedence over ConnectionString) if (customOptions is not null && customOptions.Count > 0) { var hostOpt = customOptions.FirstOrDefault(e => e.Key.Equals("host", StringComparison.OrdinalIgnoreCase)); if (hostOpt is not null) { connectionBuilder.Host = hostOpt.Value; } var portOpt = customOptions.FirstOrDefault(e => e.Key.Equals("port", StringComparison.OrdinalIgnoreCase)); if (portOpt is not null && int.TryParse(portOpt.Value, out var portValue)) { connectionBuilder.Port = portValue; } var databaseOpt = customOptions.FirstOrDefault(e => e.Key.Equals("database", StringComparison.OrdinalIgnoreCase)); if (databaseOpt is not null) { connectionBuilder.Database = databaseOpt.Value; } var usernameOpt = customOptions.FirstOrDefault(e => e.Key.Equals("username", StringComparison.OrdinalIgnoreCase)); if (usernameOpt is not null) { connectionBuilder.Username = usernameOpt.Value; } var passwordOpt = customOptions.FirstOrDefault(e => e.Key.Equals("password", StringComparison.OrdinalIgnoreCase)); if (passwordOpt is not null) { connectionBuilder.Password = passwordOpt.Value; } var timeoutOpt = customOptions.FirstOrDefault(e => e.Key.Equals("connection-timeout", StringComparison.OrdinalIgnoreCase)); if (timeoutOpt is not null && int.TryParse(timeoutOpt.Value, out var timeoutValue)) { connectionBuilder.Timeout = timeoutValue; } logger.LogDebug("EnsureDatabaseExists: Applied individual option overrides"); } // Extract final values from builder (ensure non-null for parameters) var host = connectionBuilder.Host ?? "localhost"; var port = connectionBuilder.Port; var database = connectionBuilder.Database ?? "jellyfin"; var username = connectionBuilder.Username ?? "jellyfin"; var password = connectionBuilder.Password ?? string.Empty; var timeout = connectionBuilder.Timeout; // Connect to 'postgres' maintenance database to check if target database exists var maintenanceConnectionBuilder = new NpgsqlConnectionStringBuilder { Host = host, Port = port, Database = "postgres", // Connect to maintenance database Username = username, Password = password, Timeout = timeout, Pooling = false // Don't pool maintenance connections }; try { await using var connection = new NpgsqlConnection(maintenanceConnectionBuilder.ToString()); await connection.OpenAsync(cancellationToken).ConfigureAwait(false); // Check if database exists var checkQuery = "SELECT 1 FROM pg_database WHERE datname = @databaseName"; await using var checkCommand = new NpgsqlCommand(checkQuery, connection); checkCommand.Parameters.AddWithValue("databaseName", database); var exists = await checkCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); if (exists is null) { // Database doesn't exist, create it logger.LogInformation("PostgreSQL database '{Database}' does not exist. Creating...", database); // Use identifier quoting for safety var createQuery = $"CREATE DATABASE \"{database}\" OWNER \"{username}\""; await using var createCommand = new NpgsqlCommand(createQuery, connection); await createCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); logger.LogInformation("PostgreSQL database '{Database}' created successfully", database); // Grant privileges var grantQuery = $"GRANT ALL PRIVILEGES ON DATABASE \"{database}\" TO \"{username}\""; await using var grantCommand = new NpgsqlCommand(grantQuery, connection); await grantCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); logger.LogInformation("Granted all privileges on database '{Database}' to user '{Username}'", database, username); } else { logger.LogInformation("PostgreSQL database '{Database}' already exists", database); } // Now ensure all schemas exist in the database await EnsureSchemasExistAsync(host, port, database, username, password, timeout, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { logger.LogError(ex, "Failed to ensure PostgreSQL database '{Database}' exists. Error: {Message}", database, ex.Message); throw; } } /// /// Ensures all required schemas exist in the PostgreSQL database. /// private async Task EnsureSchemasExistAsync( string host, int port, string database, string username, string password, int timeout, CancellationToken cancellationToken) { var connectionBuilder = new NpgsqlConnectionStringBuilder { Host = host, Port = port, Database = database, Username = username, Password = password, Timeout = timeout, Pooling = false }; try { await using var connection = new NpgsqlConnection(connectionBuilder.ToString()); await connection.OpenAsync(cancellationToken).ConfigureAwait(false); foreach (var schema in Schemas.All) { // Check if schema exists var checkQuery = "SELECT 1 FROM information_schema.schemata WHERE schema_name = @schemaName"; await using var checkCommand = new NpgsqlCommand(checkQuery, connection); checkCommand.Parameters.AddWithValue("schemaName", schema); var exists = await checkCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); if (exists is null) { // Schema doesn't exist, create it logger.LogInformation("Creating PostgreSQL schema '{Schema}'...", schema); var createQuery = $"CREATE SCHEMA \"{schema}\" AUTHORIZATION \"{username}\""; await using var createCommand = new NpgsqlCommand(createQuery, connection); await createCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); logger.LogInformation("Schema '{Schema}' created successfully", schema); } else { logger.LogDebug("Schema '{Schema}' already exists", schema); } } logger.LogInformation("All required PostgreSQL schemas are ready"); } catch (Exception ex) { logger.LogError(ex, "Failed to ensure PostgreSQL schemas exist. Error: {Message}", ex.Message); throw; } } /// /// Ensures all required database tables exist by applying pending migrations. /// /// Cancellation token. /// A task representing the asynchronous operation. public async Task EnsureTablesExistAsync(CancellationToken cancellationToken = default) { try { // First ensure the database itself exists if (databaseConfig is not null) { await EnsureDatabaseExistsAsync(databaseConfig, cancellationToken).ConfigureAwait(false); } else { logger.LogWarning("Database configuration not available. Database existence check skipped."); } logger.LogInformation("Checking PostgreSQL database schema..."); var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { var connection = context.Database.GetDbConnection(); var wasConnectionOpened = false; try { if (connection.State != System.Data.ConnectionState.Open) { await connection.OpenAsync(cancellationToken).ConfigureAwait(false); wasConnectionOpened = true; } // Check if database has tables (not just schemas) // Query for a critical table that must exist - if it doesn't, database needs initialization var tableCheckQuery = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'users' AND table_name = 'Users' AND table_type = 'BASE TABLE'"; await using (var tableCheckCommand = connection.CreateCommand()) { tableCheckCommand.CommandText = tableCheckQuery; var usersTableExists = (long?)await tableCheckCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); if (usersTableExists == 0) { // Database tables don't exist - initialize from SQL script logger.LogInformation("Database tables not found (users.Users table missing). Initializing from SQL script..."); var schemaScriptPath = Path.Combine(AppContext.BaseDirectory, "sql", "schema_init", "create_database_schema.sql"); if (!File.Exists(schemaScriptPath)) { logger.LogError("Schema script not found at: {Path}", schemaScriptPath); logger.LogError("Cannot initialize database automatically. Please run the SQL script manually."); throw new FileNotFoundException($"Schema initialization script not found: {schemaScriptPath}"); } logger.LogInformation("Found schema script at: {Path}", schemaScriptPath); logger.LogInformation("Executing create_database_schema.sql (this may take several minutes)..."); try { // The SQL file contains psql meta-commands (\restrict, etc.) that can't be executed via ExecuteNonQuery // We need to use psql subprocess instead var connectionString = connection.ConnectionString; var builder = new NpgsqlConnectionStringBuilder(connectionString); // Build psql command var psqlArgs = $"-h {builder.Host} -p {builder.Port} -U {builder.Username} -d {builder.Database} -f \"{schemaScriptPath}\""; // Log command (password not in args, so safe to log) logger.LogInformation("Executing via psql: psql {Args}", psqlArgs); var psqlProcess = new System.Diagnostics.Process { StartInfo = new System.Diagnostics.ProcessStartInfo { FileName = "psql", Arguments = psqlArgs, RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = true, UseShellExecute = false, CreateNoWindow = true } }; // Set password via environment variable if (!string.IsNullOrEmpty(builder.Password)) { psqlProcess.StartInfo.EnvironmentVariables["PGPASSWORD"] = builder.Password; } psqlProcess.Start(); // Read output var output = await psqlProcess.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); var error = await psqlProcess.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); await psqlProcess.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (psqlProcess.ExitCode == 0) { logger.LogInformation("✅ Successfully initialized database from SQL script via psql"); logger.LogInformation("Database is ready. You can now start Jellyfin."); if (!string.IsNullOrWhiteSpace(output)) { logger.LogDebug("psql output: {Output}", output); } } else { logger.LogError("psql exited with code {ExitCode}", psqlProcess.ExitCode); if (!string.IsNullOrWhiteSpace(error)) { logger.LogError("psql error: {Error}", error); } throw new InvalidOperationException($"psql failed with exit code {psqlProcess.ExitCode}. Error: {error}"); } } catch (System.ComponentModel.Win32Exception psqlNotFound) { // psql not found in PATH - fallback to manual instructions logger.LogWarning(psqlNotFound, "psql command not found in PATH. Cannot automatically initialize database."); logger.LogError("Please run the SQL script manually: psql -U postgres -d jellyfin_testdata -f {Path}", schemaScriptPath); throw new FileNotFoundException("psql command not found. Please install PostgreSQL client tools and ensure psql is in your PATH, or run the SQL script manually.", psqlNotFound); } catch (Exception scriptEx) { logger.LogError(scriptEx, "Failed to execute schema creation script"); logger.LogError("Please run the SQL script manually: psql -U postgres -d jellyfin_testdata -f {Path}", schemaScriptPath); throw; } } else { logger.LogInformation("Database tables exist (users.Users table found)"); } } // Verify all expected schemas and tables exist foreach (var schema in Schemas.All) { var tableCountQuery = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = $1 AND table_type = 'BASE TABLE'"; await using (var command = connection.CreateCommand()) { command.CommandText = tableCountQuery; var parameter = command.CreateParameter(); parameter.Value = schema; command.Parameters.Add(parameter); var tableCount = (long?)await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); if (tableCount > 0) { logger.LogDebug("Schema '{Schema}' contains {TableCount} tables", schema, tableCount); } else { logger.LogWarning("Schema '{Schema}' exists but contains no tables. This may indicate an incomplete initialization.", schema); } } } logger.LogInformation("✅ Database schema verification complete"); } finally { if (wasConnectionOpened && connection.State == System.Data.ConnectionState.Open) { await connection.CloseAsync().ConfigureAwait(false); } } } // NOTE: EF Core migrations are DISABLED for .NET 11 preview // Database schema is created via SQL scripts (create_database_schema.sql) // Any future migrations must be applied manually using SQL scripts logger.LogInformation("EF Core migration system is disabled for .NET 11 preview. Use SQL scripts for schema changes."); } catch (Exception ex) { logger.LogError(ex, "Failed to verify or initialize PostgreSQL database"); throw; } } /// /// Manually records migrations as applied in the migrations history table. /// This is used to recover from situations where tables were created but the history wasn't updated. /// /// The database context. /// List of migration IDs to record. /// Cancellation token. private async Task RecordMigrationAsAppliedAsync(JellyfinDbContext context, IList migrationIds, CancellationToken cancellationToken) { var connection = context.Database.GetDbConnection(); var wasOpened = false; try { if (connection.State != System.Data.ConnectionState.Open) { await connection.OpenAsync(cancellationToken).ConfigureAwait(false); wasOpened = true; } // Get the current product version from the assembly var productVersion = typeof(JellyfinDbContext).Assembly .GetCustomAttribute()? .InformationalVersion ?? "Unknown"; foreach (var migrationId in migrationIds) { logger.LogInformation("Recording migration '{MigrationId}' as applied", migrationId); var insertQuery = @" INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"") VALUES (@migrationId, @productVersion) ON CONFLICT (""MigrationId"") DO NOTHING"; await using (var command = connection.CreateCommand()) { command.CommandText = insertQuery; var migrationParam = command.CreateParameter(); migrationParam.ParameterName = "@migrationId"; migrationParam.Value = migrationId; command.Parameters.Add(migrationParam); var versionParam = command.CreateParameter(); versionParam.ParameterName = "@productVersion"; versionParam.Value = productVersion; command.Parameters.Add(versionParam); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } } logger.LogInformation("Successfully recorded {Count} migrations in history table", migrationIds.Count); } finally { if (wasOpened) { await connection.CloseAsync().ConfigureAwait(false); } } } /// public async Task RunScheduledOptimisation(CancellationToken cancellationToken) { var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { // Get the database connection var connection = context.Database.GetDbConnection(); var wasOpened = false; try { if (connection.State != System.Data.ConnectionState.Open) { await connection.OpenAsync(cancellationToken).ConfigureAwait(false); wasOpened = true; } // Run PostgreSQL optimization commands on all tables in each schema foreach (var schema in Schemas.All) { // Get all tables in the schema var tablesQuery = @" SELECT table_name FROM information_schema.tables WHERE table_schema = @schema AND table_type = 'BASE TABLE'"; var tables = new List(); await using (var command = connection.CreateCommand()) { command.CommandText = tablesQuery; var parameter = command.CreateParameter(); parameter.ParameterName = "@schema"; parameter.Value = schema; command.Parameters.Add(parameter); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { tables.Add(reader.GetString(0)); } } // Vacuum each table in the schema foreach (var table in tables) { #pragma warning disable EF1002 // Schema and table names are from database metadata, not user input await context.Database.ExecuteSqlRawAsync($"VACUUM ANALYZE \"{schema}\".\"{table}\"", cancellationToken).ConfigureAwait(false); #pragma warning restore EF1002 logger.LogDebug("Optimized PostgreSQL table '{Schema}.{Table}'", schema, table); } logger.LogDebug("Completed optimization for PostgreSQL schema '{Schema}' ({TableCount} tables)", schema, tables.Count); } logger.LogInformation("PostgreSQL database optimized successfully!"); } finally { if (wasOpened) { await connection.CloseAsync().ConfigureAwait(false); } } } } /// public void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc); // Assign entities to schemas based on their legacy database origin AssignEntitiesToSchemas(modelBuilder); } /// /// Assigns entities to PostgreSQL schemas based on their legacy database origin. /// /// The model builder. private static void AssignEntitiesToSchemas(ModelBuilder modelBuilder) { // ActivityLog schema (legacy: activitylog.db) modelBuilder.Entity().ToTable("ActivityLogs", Schemas.ActivityLog); // Authentication schema (legacy: authentication.db) modelBuilder.Entity().ToTable("ApiKeys", Schemas.Authentication); modelBuilder.Entity().ToTable("Devices", Schemas.Authentication); modelBuilder.Entity().ToTable("DeviceOptions", Schemas.Authentication); // DisplayPreferences schema (legacy: displaypreferences.db) modelBuilder.Entity().ToTable("DisplayPreferences", Schemas.DisplayPreferences); modelBuilder.Entity().ToTable("ItemDisplayPreferences", Schemas.DisplayPreferences); modelBuilder.Entity().ToTable("CustomItemDisplayPreferences", Schemas.DisplayPreferences); modelBuilder.Entity().ToTable("HomeSections", Schemas.DisplayPreferences); // Users schema (legacy: users.db) modelBuilder.Entity().ToTable("Users", Schemas.Users); modelBuilder.Entity().ToTable("Permissions", Schemas.Users); modelBuilder.Entity().ToTable("Preferences", Schemas.Users); modelBuilder.Entity().ToTable("AccessSchedules", Schemas.Users); // Library schema (legacy: library.db) - All remaining entities modelBuilder.Entity().ToTable("BaseItems", Schemas.Library); modelBuilder.Entity().ToTable("Chapters", Schemas.Library); modelBuilder.Entity().ToTable("MediaStreamInfos", Schemas.Library); modelBuilder.Entity().ToTable("AttachmentStreamInfos", Schemas.Library); modelBuilder.Entity().ToTable("ImageInfos", Schemas.Library); modelBuilder.Entity().ToTable("BaseItemImageInfos", Schemas.Library); modelBuilder.Entity().ToTable("BaseItemProviders", Schemas.Library); modelBuilder.Entity().ToTable("BaseItemMetadataFields", Schemas.Library); modelBuilder.Entity().ToTable("BaseItemTrailerTypes", Schemas.Library); modelBuilder.Entity().ToTable("ItemValues", Schemas.Library); modelBuilder.Entity().ToTable("ItemValuesMap", Schemas.Library); modelBuilder.Entity().ToTable("Peoples", Schemas.Library); modelBuilder.Entity().ToTable("PeopleBaseItemMap", Schemas.Library); modelBuilder.Entity().ToTable("UserData", Schemas.Library); modelBuilder.Entity().ToTable("AncestorIds", Schemas.Library); modelBuilder.Entity().ToTable("TrickplayInfos", Schemas.Library); modelBuilder.Entity().ToTable("MediaSegments", Schemas.Library); modelBuilder.Entity().ToTable("KeyframeData", Schemas.Library); } /// public Task RunShutdownTask(CancellationToken cancellationToken) { if (DbContextFactory is null) { return Task.CompletedTask; } // Clear connection pools NpgsqlConnection.ClearAllPools(); return Task.CompletedTask; } /// public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { // PostgreSQL-specific conventions can be added here if needed } /// public async Task MigrationBackupFast(CancellationToken cancellationToken) { if (IsLocalHost(currentHost) && backupService is not null) { try { var backupDirectory = Path.Combine(applicationPaths.DataPath, "backups"); var backupPath = await backupService.CreateBackupAsync(backupDirectory, cancellationToken).ConfigureAwait(false); logger.LogInformation("PostgreSQL local backup created: {BackupPath}", backupPath); return Path.GetFileName(backupPath); } catch (PostgresVersionMismatchException versionEx) { logger.LogError( versionEx, "PostgreSQL version mismatch: Server version {ServerVersion} is incompatible with pg_dump version {ClientVersion}. " + "Please upgrade your PostgreSQL client tools (pg_dump/pg_restore) to match or exceed the server version. " + "You can install the latest PostgreSQL client tools from: https://www.postgresql.org/download/", versionEx.ServerVersion, versionEx.ClientVersion); throw; } catch (Exception ex) { logger.LogError(ex, "Failed to create local PostgreSQL backup using pg_dump"); throw; } } var key = DateTime.UtcNow.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture); logger.LogWarning("Fast backup is not implemented for PostgreSQL on a remote server. Use pg_dump manually for proper backups."); return await Task.FromResult(key).ConfigureAwait(false); } /// public async Task RestoreBackupFast(string key, CancellationToken cancellationToken) { if (IsLocalHost(currentHost) && backupService is not null) { try { var backupDirectory = Path.Combine(applicationPaths.DataPath, "backups"); var backupPath = Path.Combine(backupDirectory, key); if (!File.Exists(backupPath)) { // Try to find the backup file with common extensions var possibleExtensions = new[] { ".dump", ".sql", ".tar" }; foreach (var ext in possibleExtensions) { var pathWithExt = backupPath + ext; if (File.Exists(pathWithExt)) { backupPath = pathWithExt; break; } } } if (File.Exists(backupPath)) { await backupService.RestoreBackupAsync(backupPath, cancellationToken).ConfigureAwait(false); logger.LogInformation("PostgreSQL local backup restored from: {BackupPath}", backupPath); return; } logger.LogWarning("Backup file not found: {BackupPath}", backupPath); } catch (Exception ex) { logger.LogError(ex, "Failed to restore local PostgreSQL backup using pg_restore"); throw; } } logger.LogWarning("Fast restore is not implemented for PostgreSQL on a remote server. Use pg_restore manually for proper restoration."); } /// public Task DeleteBackup(string key) { if (IsLocalHost(currentHost) && backupService is not null) { try { var backupDirectory = Path.Combine(applicationPaths.DataPath, "backups"); var backupPath = Path.Combine(backupDirectory, key); // Try to find and delete the backup file if (File.Exists(backupPath)) { File.Delete(backupPath); logger.LogInformation("Deleted local PostgreSQL backup: {BackupPath}", backupPath); } else { // Try with common extensions var possibleExtensions = new[] { ".dump", ".sql", ".tar" }; foreach (var ext in possibleExtensions) { var pathWithExt = backupPath + ext; if (File.Exists(pathWithExt)) { File.Delete(pathWithExt); logger.LogInformation("Deleted local PostgreSQL backup: {BackupPath}", pathWithExt); return Task.CompletedTask; } } logger.LogWarning("Backup file not found for deletion: {BackupPath}", backupPath); } } catch (Exception ex) { logger.LogError(ex, "Failed to delete local PostgreSQL backup: {BackupKey}", key); } return Task.CompletedTask; } logger.LogWarning("Backup deletion is not implemented for PostgreSQL on a remote server. Manage backups externally."); return Task.CompletedTask; } /// /// Determines if the given host is localhost. /// /// The host to check. /// True if the host is localhost or 127.0.0.1, false otherwise. private static bool IsLocalHost(string? host) { if (string.IsNullOrWhiteSpace(host)) { return false; } return host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || host.Equals("127.0.0.1", StringComparison.Ordinal) || host.Equals("::1", StringComparison.Ordinal); // IPv6 localhost } /// public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable? tableNames) { ArgumentNullException.ThrowIfNull(tableNames); var deleteQueries = new List(); foreach (var tableName in tableNames) { // Find the schema for this table var entityType = dbContext.Model.GetEntityTypes() .FirstOrDefault(et => et.GetTableName() == tableName); if (entityType is not null) { var schema = entityType.GetSchema() ?? "public"; deleteQueries.Add($"TRUNCATE TABLE \"{schema}\".\"{tableName}\" CASCADE;"); } else { // Fallback to public schema if entity not found deleteQueries.Add($"TRUNCATE TABLE \"public\".\"{tableName}\" CASCADE;"); } } var deleteAllQuery = string.Join('\n', deleteQueries); await dbContext.Database.ExecuteSqlRawAsync(deleteAllQuery).ConfigureAwait(false); } }