// // Copyright (c) PlaceholderCompany. All rights reserved. // namespace Jellyfin.Database.Providers.Postgres; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations.DbConfiguration; using Jellyfin.Database.Providers.Postgres.Exceptions; using MediaBrowser.Common.Configuration; using Microsoft.Extensions.Logging; /// /// Service for creating and restoring PostgreSQL database backups using pg_dump/pg_restore. /// public class PostgresBackupService { private readonly ILogger _logger; private readonly IConfigurationManager _configurationManager; /// /// Initializes a new instance of the class. /// /// The logger. /// The configuration manager. public PostgresBackupService( ILogger logger, IConfigurationManager configurationManager) { _logger = logger; _configurationManager = configurationManager; } /// /// Creates a backup of the PostgreSQL database. /// /// The directory to save the backup file. /// Cancellation token. /// The path to the created backup file. public async Task CreateBackupAsync(string backupDirectory, CancellationToken cancellationToken = default) { var dbConfig = _configurationManager.GetConfiguration("database"); var backupOptions = dbConfig.BackupOptions ?? new DatabaseBackupOptions(); if (dbConfig.CustomProviderOptions?.ConnectionString == null) { throw new InvalidOperationException("Database connection string is not configured"); } var connectionString = dbConfig.CustomProviderOptions.ConnectionString; var connectionParams = ParseConnectionString(connectionString); var pgDumpPath = FindExecutable(backupOptions.PgDumpPath, "pg_dump"); var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); var extension = GetFileExtension(backupOptions.BackupFormat); var backupFileName = $"jellyfin_postgres_backup_{timestamp}.{extension}"; var fullBackupPath = Path.Combine(backupDirectory, backupFileName); Directory.CreateDirectory(backupDirectory); var arguments = BuildPgDumpArguments(connectionParams, backupOptions, fullBackupPath); _logger.LogInformation("Starting PostgreSQL backup using external pg_dump tool to {BackupPath}", fullBackupPath); _logger.LogDebug("Executing external command: {PgDump} {Arguments}", pgDumpPath, string.Join(" ", arguments.Where(a => !a.Contains("PGPASSWORD", StringComparison.OrdinalIgnoreCase)))); var processStartInfo = new ProcessStartInfo { FileName = pgDumpPath, Arguments = string.Join(" ", arguments), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; // Set password via environment variable (more secure than command line) if (connectionParams.TryGetValue("Password", out var password)) { processStartInfo.EnvironmentVariables["PGPASSWORD"] = password; } using var process = new Process { StartInfo = processStartInfo }; var outputBuilder = new StringBuilder(); var errorBuilder = new StringBuilder(); process.OutputDataReceived += (sender, e) => { if (e.Data != null) { outputBuilder.AppendLine(e.Data); if (backupOptions.VerboseOutput) { _logger.LogDebug("pg_dump: {Output}", e.Data); } } }; process.ErrorDataReceived += (sender, e) => { if (e.Data != null) { errorBuilder.AppendLine(e.Data); if (backupOptions.VerboseOutput) { _logger.LogDebug("pg_dump stderr: {Error}", e.Data); } } }; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); // Wait with timeout using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(backupOptions.TimeoutSeconds)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); try { await process.WaitForExitAsync(linkedCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) { process.Kill(); throw new TimeoutException($"Backup operation timed out after {backupOptions.TimeoutSeconds} seconds"); } if (process.ExitCode != 0) { var errorMessage = errorBuilder.ToString(); _logger.LogError("pg_dump failed with exit code {ExitCode}: {Error}", process.ExitCode, errorMessage); // Check for version mismatch error if (errorMessage.Contains("server version mismatch", StringComparison.OrdinalIgnoreCase)) { var (serverVersion, clientVersion) = ParseVersionMismatchError(errorMessage); _logger.LogError( "PostgreSQL version mismatch detected - Server: {ServerVersion}, pg_dump: {ClientVersion}. " + "Please upgrade your PostgreSQL client tools to match the server version.", serverVersion, clientVersion); // Clean up failed backup file if (File.Exists(fullBackupPath)) { File.Delete(fullBackupPath); } throw new PostgresVersionMismatchException(serverVersion, clientVersion); } // Clean up failed backup file if (File.Exists(fullBackupPath)) { File.Delete(fullBackupPath); } throw new Exception($"Database backup failed: {errorMessage}"); } var fileInfo = new FileInfo(fullBackupPath); _logger.LogInformation( "Successfully created PostgreSQL backup at {BackupPath} ({Size:N0} bytes)", fullBackupPath, fileInfo.Length); return fullBackupPath; } /// /// Restores a PostgreSQL database from a backup file. /// /// The path to the backup file. /// Cancellation token. /// A task representing the restore operation. public async Task RestoreBackupAsync(string backupFilePath, CancellationToken cancellationToken = default) { var dbConfig = _configurationManager.GetConfiguration("database"); var backupOptions = dbConfig.BackupOptions ?? new DatabaseBackupOptions(); if (!File.Exists(backupFilePath)) { throw new FileNotFoundException($"Backup file not found: {backupFilePath}"); } if (dbConfig.CustomProviderOptions?.ConnectionString == null) { throw new InvalidOperationException("Database connection string is not configured"); } var connectionString = dbConfig.CustomProviderOptions.ConnectionString; var connectionParams = ParseConnectionString(connectionString); var pgRestorePath = FindExecutable(backupOptions.PgRestorePath, "pg_restore"); var arguments = BuildPgRestoreArguments(connectionParams, backupOptions, backupFilePath); _logger.LogInformation("Starting PostgreSQL restore from {BackupPath}", backupFilePath); var processStartInfo = new ProcessStartInfo { FileName = pgRestorePath, Arguments = string.Join(" ", arguments), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; if (connectionParams.TryGetValue("Password", out var password)) { processStartInfo.EnvironmentVariables["PGPASSWORD"] = password; } using var process = new Process { StartInfo = processStartInfo }; var outputBuilder = new StringBuilder(); var errorBuilder = new StringBuilder(); process.OutputDataReceived += (sender, e) => { if (e.Data != null) { outputBuilder.AppendLine(e.Data); if (backupOptions.VerboseOutput) { _logger.LogDebug("pg_restore: {Output}", e.Data); } } }; process.ErrorDataReceived += (sender, e) => { if (e.Data != null) { errorBuilder.AppendLine(e.Data); if (backupOptions.VerboseOutput) { _logger.LogDebug("pg_restore stderr: {Error}", e.Data); } } }; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(backupOptions.TimeoutSeconds)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); try { await process.WaitForExitAsync(linkedCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) { process.Kill(); throw new TimeoutException($"Restore operation timed out after {backupOptions.TimeoutSeconds} seconds"); } if (process.ExitCode != 0) { var errorMessage = errorBuilder.ToString(); _logger.LogError("pg_restore failed with exit code {ExitCode}: {Error}", process.ExitCode, errorMessage); throw new Exception($"Database restore failed: {errorMessage}"); } _logger.LogInformation("Successfully restored PostgreSQL backup from {BackupPath}", backupFilePath); } private static Dictionary ParseConnectionString(string connectionString) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); var parts = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries); foreach (var part in parts) { var kvp = part.Split('=', 2); if (kvp.Length == 2) { result[kvp[0].Trim()] = kvp[1].Trim(); } } return result; } private List BuildPgDumpArguments( Dictionary connectionParams, DatabaseBackupOptions options, string outputPath) { var args = new List(); // Connection parameters if (connectionParams.TryGetValue("Host", out var host)) { args.Add($"-h {host}"); } if (connectionParams.TryGetValue("Port", out var port)) { args.Add($"-p {port}"); } if (connectionParams.TryGetValue("Username", out var username)) { args.Add($"-U {username}"); } if (connectionParams.TryGetValue("Database", out var database)) { args.Add($"-d {database}"); } // Format var formatFlag = options.BackupFormat.ToLowerInvariant() switch { "plain" => "p", "custom" => "c", "directory" => "d", "tar" => "t", _ => "c" }; args.Add($"-F {formatFlag}"); // Compression level (for custom and directory formats) if (formatFlag is "c" or "d") { args.Add($"-Z {options.CompressionLevel}"); } // Include blobs if (options.IncludeBlobs) { args.Add("-b"); } // Verbose output if (options.VerboseOutput) { args.Add("-v"); } // Parallel jobs (directory format only) if (options.ParallelJobs.HasValue && formatFlag == "d") { args.Add($"-j {options.ParallelJobs.Value}"); } // Additional arguments if (!string.IsNullOrWhiteSpace(options.AdditionalArguments)) { args.Add(options.AdditionalArguments); } // Output file args.Add($"-f \"{outputPath}\""); return args; } private List BuildPgRestoreArguments( Dictionary connectionParams, DatabaseBackupOptions options, string backupPath) { var args = new List(); // Connection parameters if (connectionParams.TryGetValue("Host", out var host)) { args.Add($"-h {host}"); } if (connectionParams.TryGetValue("Port", out var port)) { args.Add($"-p {port}"); } if (connectionParams.TryGetValue("Username", out var username)) { args.Add($"-U {username}"); } if (connectionParams.TryGetValue("Database", out var database)) { args.Add($"-d {database}"); } // Clean before restore args.Add("--clean"); args.Add("--if-exists"); // Verbose output if (options.VerboseOutput) { args.Add("-v"); } // Parallel jobs if (options.ParallelJobs.HasValue) { args.Add($"-j {options.ParallelJobs.Value}"); } // Input file args.Add($"\"{backupPath}\""); return args; } private string FindExecutable(string? configuredPath, string defaultExecutableName) { // If configured path is provided, use it if (!string.IsNullOrWhiteSpace(configuredPath)) { if (File.Exists(configuredPath)) { return configuredPath; } _logger.LogWarning("Configured path {Path} does not exist, searching in PATH", configuredPath); } // Try to find in PATH var possiblePaths = new[] { defaultExecutableName, // In PATH Path.Combine("/usr/bin", defaultExecutableName), Path.Combine("/usr/local/bin", defaultExecutableName), Path.Combine(@"C:\Program Files\PostgreSQL\16\bin", $"{defaultExecutableName}.exe"), Path.Combine(@"C:\Program Files\PostgreSQL\15\bin", $"{defaultExecutableName}.exe"), Path.Combine(@"C:\Program Files\PostgreSQL\14\bin", $"{defaultExecutableName}.exe"), }; foreach (var path in possiblePaths) { if (File.Exists(path)) { _logger.LogDebug("Found {Executable} at {Path}", defaultExecutableName, path); return path; } // Check if it's in PATH by trying to execute --version if (IsInPath(path)) { _logger.LogDebug("Found {Executable} in PATH", defaultExecutableName); return path; } } throw new FileNotFoundException($"{defaultExecutableName} executable not found. Please configure the path in database.xml or ensure it's in the system PATH."); } private bool IsInPath(string fileName) { try { using var process = new Process { StartInfo = new ProcessStartInfo { FileName = fileName, Arguments = "--version", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } }; process.Start(); process.WaitForExit(2000); return process.ExitCode == 0; } catch { return false; } } private static string GetFileExtension(string format) { return format.ToLowerInvariant() switch { "plain" => "sql", "custom" => "dump", "directory" => "dir", "tar" => "tar", _ => "dump" }; } /// /// Parses PostgreSQL version mismatch error message to extract server and client versions. /// /// The error message from pg_dump. /// A tuple containing server version and client version. private static (string ServerVersion, string ClientVersion) ParseVersionMismatchError(string errorMessage) { var serverVersion = "unknown"; var clientVersion = "unknown"; // Parse server version: "server version: 18.3 (Debian 18.3-1.pgdg13+1)" var serverMatch = Regex.Match(errorMessage, @"server version:\s*([^\s;]+(?:\s*\([^)]+\))?)", RegexOptions.IgnoreCase); if (serverMatch.Success) { serverVersion = serverMatch.Groups[1].Value.Trim(); } // Parse pg_dump version: "pg_dump version: 16.12 (Ubuntu 16.12-1.pgdg24.04+1)" var clientMatch = Regex.Match(errorMessage, @"pg_dump version:\s*([^\s;]+(?:\s*\([^)]+\))?)", RegexOptions.IgnoreCase); if (clientMatch.Success) { clientVersion = clientMatch.Groups[1].Value.Trim(); } return (serverVersion, clientVersion); } }