Add user-configurable PostgreSQL backup system

Introduced backup/restore via external pg_dump/pg_restore tools, configurable in database.xml. Added DatabaseBackupOptions, PostgresBackupService, and example/config docs. Backup features are enabled only for local databases; remote DBs require manual management. Logging and documentation clarify external tool usage, error handling, and security. Updated provider logic and assembly files accordingly.
This commit is contained in:
2026-02-23 18:31:27 -05:00
parent 43cdbf9b35
commit 88dba54087
16 changed files with 1637 additions and 14 deletions
@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Jellyfin.CodeAnalysis")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+534b0cde918839aca165b7bd2b1c7df761aca82c")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+43cdbf9b350e608dce121070d565cdce0aea6b17")]
[assembly: System.Reflection.AssemblyProductAttribute("Jellyfin.CodeAnalysis")]
[assembly: System.Reflection.AssemblyTitleAttribute("Jellyfin.CodeAnalysis")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
16d1d32a5e6e4b7472006b4665d10d7735bf11c7602c8304312d34d2b63afa7f
bdef8325a5a74be1c67e80979e05ef8eaf8569901dd2077003e91c35b6d8e7dd
@@ -0,0 +1,75 @@
// <copyright file="DatabaseBackupOptions.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Database.Implementations.DbConfiguration;
/// <summary>
/// Options to configure database backup behavior.
/// </summary>
public class DatabaseBackupOptions
{
/// <summary>
/// Gets or sets the path to the pg_dump executable.
/// If not specified, will search in system PATH.
/// </summary>
/// <remarks>
/// Examples:
/// - Windows: "C:\\Program Files\\PostgreSQL\\16\\bin\\pg_dump.exe"
/// - Linux: "/usr/bin/pg_dump"
/// - Use "pg_dump" to search in PATH.
/// </remarks>
public string? PgDumpPath { get; set; }
/// <summary>
/// Gets or sets the path to the pg_restore executable.
/// If not specified, will search in system PATH.
/// </summary>
public string? PgRestorePath { get; set; }
/// <summary>
/// Gets or sets the backup format.
/// </summary>
/// <remarks>
/// Valid values:
/// - "custom" (default): Custom compressed format (pg_restore compatible)
/// - "plain": Plain SQL script
/// - "directory": Directory format
/// - "tar": Tar archive format.
/// </remarks>
public string BackupFormat { get; set; } = "custom";
/// <summary>
/// Gets or sets a value indicating whether to include large objects (blobs) in backup.
/// </summary>
public bool IncludeBlobs { get; set; } = true;
/// <summary>
/// Gets or sets the compression level (0-9, where 9 is maximum compression).
/// Only applies to custom and directory formats.
/// </summary>
public int CompressionLevel { get; set; } = 6;
/// <summary>
/// Gets or sets additional pg_dump arguments.
/// </summary>
/// <remarks>
/// Example: "--verbose --exclude-table-data=public.logs".
/// </remarks>
public string? AdditionalArguments { get; set; }
/// <summary>
/// Gets or sets the timeout in seconds for backup operations.
/// </summary>
public int TimeoutSeconds { get; set; } = 1800; // 30 minutes
/// <summary>
/// Gets or sets a value indicating whether to enable verbose output.
/// </summary>
public bool VerboseOutput { get; set; } = true;
/// <summary>
/// Gets or sets the number of parallel jobs for pg_dump (if format is 'directory').
/// </summary>
public int? ParallelJobs { get; set; }
}
@@ -4,8 +4,6 @@
namespace Jellyfin.Database.Implementations.DbConfiguration;
using System.Collections.Generic;
/// <summary>
/// Options to configure jellyfins managed database.
/// </summary>
@@ -26,4 +24,9 @@ public class DatabaseConfigurationOptions
/// Defaults to "NoLock".
/// </summary>
public DatabaseLockingBehaviorTypes LockingBehavior { get; set; }
/// <summary>
/// Gets or sets the backup configuration options.
/// </summary>
public DatabaseBackupOptions? BackupOptions { get; set; }
}
@@ -0,0 +1,73 @@
<!--
Example database.xml configuration for Jellyfin with PostgreSQL backup options
Place this file in your Jellyfin config directory
-->
<DatabaseConfigurationOptions>
<!-- Database type: "Jellyfin-PostgreSQL" or "Jellyfin-Sqlite" -->
<DatabaseType>Jellyfin-PostgreSQL</DatabaseType>
<!-- Locking behavior: "NoLock", "Pessimistic", or "Optimistic" -->
<LockingBehavior>NoLock</LockingBehavior>
<!-- Custom database provider options (for PostgreSQL) -->
<CustomProviderOptions>
<PluginName>Jellyfin-PostgreSQL</PluginName>
<PluginAssembly>Jellyfin.Database.Providers.Postgres</PluginAssembly>
<ConnectionString>Host=localhost;Port=5432;Database=jellyfin;Username=jellyfin;Password=your_password_here</ConnectionString>
<Options>
<!-- Optional: Override host -->
<CustomDatabaseOption>
<Key>Host</Key>
<Value>localhost</Value>
</CustomDatabaseOption>
<!-- Optional: Override port -->
<CustomDatabaseOption>
<Key>Port</Key>
<Value>5432</Value>
</CustomDatabaseOption>
<!-- Optional: Override database name -->
<CustomDatabaseOption>
<Key>Database</Key>
<Value>jellyfin</Value>
</CustomDatabaseOption>
</Options>
</CustomProviderOptions>
<!-- Database Backup Configuration -->
<!-- NOTE: Backup features using pg_dump/pg_restore are ONLY enabled when -->
<!-- the database host is 'localhost' or '127.0.0.1'. For remote databases, -->
<!-- these settings will be ignored and you must manage backups externally. -->
<BackupOptions>
<!-- Path to pg_dump executable (optional, will search PATH if not specified) -->
<!-- Windows example: C:\Program Files\PostgreSQL\16\bin\pg_dump.exe -->
<!-- Linux example: /usr/bin/pg_dump -->
<PgDumpPath>pg_dump</PgDumpPath>
<!-- Path to pg_restore executable (optional) -->
<PgRestorePath>pg_restore</PgRestorePath>
<!-- Backup format: "custom" (default), "plain", "directory", or "tar" -->
<BackupFormat>custom</BackupFormat>
<!-- Include large objects/blobs in backup -->
<IncludeBlobs>true</IncludeBlobs>
<!-- Compression level (0-9, where 9 is maximum compression) -->
<CompressionLevel>6</CompressionLevel>
<!-- Timeout in seconds for backup operations (default: 1800 = 30 minutes) -->
<TimeoutSeconds>1800</TimeoutSeconds>
<!-- Enable verbose output in logs -->
<VerboseOutput>true</VerboseOutput>
<!-- Number of parallel jobs (only for 'directory' format) -->
<!-- <ParallelJobs>4</ParallelJobs> -->
<!-- Additional pg_dump arguments (optional) -->
<!-- <AdditionalArguments>--exclude-table-data=public.temp_logs</AdditionalArguments> -->
</BackupOptions>
</DatabaseConfigurationOptions>
@@ -0,0 +1,258 @@
# PostgreSQL Backup Configuration
This document explains how to configure PostgreSQL backup options in Jellyfin using the `database.xml` configuration file.
## **IMPORTANT: Local Database Requirement**
**pg_dump and pg_restore backup features are ONLY available when the PostgreSQL database host is `localhost` or `127.0.0.1` (or `::1` for IPv6).**
-**Local database** (localhost/127.0.0.1): Automated backups via pg_dump/pg_restore are enabled
-**Remote database**: Backup settings are ignored; you must manage backups externally
When using a remote PostgreSQL server, Jellyfin will log:
> "PostgreSQL database is on remote server ({Host}). Backup operations via pg_dump/pg_restore are disabled"
And backup operations will show:
> "Backup deletion is not implemented for PostgreSQL on a remote server. Manage backups externally."
## Configuration File Location
The `database.xml` file should be placed in your Jellyfin configuration directory:
- **Windows**: `%AppData%\Jellyfin\Server\config\database.xml`
- **Linux**: `~/.config/jellyfin/config/database.xml` or `/etc/jellyfin/config/database.xml`
- **Docker**: Mount to `/config/database.xml`
## Basic Configuration
Here's a minimal configuration for PostgreSQL with default backup settings:
```xml
<DatabaseConfigurationOptions>
<DatabaseType>Jellyfin-PostgreSQL</DatabaseType>
<LockingBehavior>NoLock</LockingBehavior>
<CustomProviderOptions>
<PluginName>Jellyfin-PostgreSQL</PluginName>
<PluginAssembly>Jellyfin.Database.Providers.Postgres</PluginAssembly>
<ConnectionString>Host=localhost;Port=5432;Database=jellyfin;Username=jellyfin;Password=your_password</ConnectionString>
</CustomProviderOptions>
<!-- Backup configuration (all settings are optional) -->
<BackupOptions>
<PgDumpPath>pg_dump</PgDumpPath>
<BackupFormat>custom</BackupFormat>
</BackupOptions>
</DatabaseConfigurationOptions>
```
## Backup Options Reference
### PgDumpPath
**Type**: `string` (optional)
**Default**: Searches in system PATH
The full path to the `pg_dump` executable. If not specified, Jellyfin will search common locations:
- Windows: `C:\Program Files\PostgreSQL\{version}\bin\pg_dump.exe`
- Linux: `/usr/bin/pg_dump`, `/usr/local/bin/pg_dump`
**Examples**:
```xml
<!-- Windows -->
<PgDumpPath>C:\Program Files\PostgreSQL\16\bin\pg_dump.exe</PgDumpPath>
<!-- Linux -->
<PgDumpPath>/usr/bin/pg_dump</PgDumpPath>
<!-- Search in PATH -->
<PgDumpPath>pg_dump</PgDumpPath>
```
### PgRestorePath
**Type**: `string` (optional)
**Default**: Searches in system PATH
Path to the `pg_restore` executable. Same search behavior as `PgDumpPath`.
### BackupFormat
**Type**: `string`
**Default**: `custom`
**Valid values**: `custom`, `plain`, `directory`, `tar`
The backup file format:
- **custom**: Compressed custom format (recommended) - can be restored with `pg_restore`
- **plain**: Plain SQL script - can be restored with `psql`
- **directory**: Directory format with one file per table
- **tar**: Tar archive format
```xml
<BackupFormat>custom</BackupFormat>
```
### IncludeBlobs
**Type**: `boolean`
**Default**: `true`
Whether to include large objects (BLOBs) in the backup.
```xml
<IncludeBlobs>true</IncludeBlobs>
```
### CompressionLevel
**Type**: `integer` (0-9)
**Default**: `6`
Compression level for custom and directory formats. Higher values mean better compression but slower speed.
- `0` = No compression
- `9` = Maximum compression
- `6` = Good balance (recommended)
```xml
<CompressionLevel>6</CompressionLevel>
```
### TimeoutSeconds
**Type**: `integer`
**Default**: `1800` (30 minutes)
Maximum time in seconds to wait for backup/restore operations to complete.
```xml
<TimeoutSeconds>1800</TimeoutSeconds>
```
### VerboseOutput
**Type**: `boolean`
**Default**: `true`
Enable verbose logging output from pg_dump/pg_restore operations.
```xml
<VerboseOutput>true</VerboseOutput>
```
### ParallelJobs
**Type**: `integer` (optional)
**Default**: `null`
Number of parallel jobs for backup operations. Only works with `directory` format.
```xml
<BackupFormat>directory</BackupFormat>
<ParallelJobs>4</ParallelJobs>
```
### AdditionalArguments
**Type**: `string` (optional)
**Default**: `null`
Additional command-line arguments to pass to pg_dump.
**Examples**:
```xml
<!-- Exclude specific table data -->
<AdditionalArguments>--exclude-table-data=public.temp_logs</AdditionalArguments>
<!-- Exclude schema -->
<AdditionalArguments>--exclude-schema=archive</AdditionalArguments>
<!-- Multiple arguments -->
<AdditionalArguments>--exclude-table-data=public.logs --exclude-schema=test</AdditionalArguments>
```
## Complete Example Configuration
```xml
<DatabaseConfigurationOptions>
<DatabaseType>Jellyfin-PostgreSQL</DatabaseType>
<LockingBehavior>NoLock</LockingBehavior>
<CustomProviderOptions>
<PluginName>Jellyfin-PostgreSQL</PluginName>
<PluginAssembly>Jellyfin.Database.Providers.Postgres</PluginAssembly>
<ConnectionString>Host=localhost;Port=5432;Database=jellyfin;Username=jellyfin;Password=your_password</ConnectionString>
</CustomProviderOptions>
<BackupOptions>
<!-- Paths to executables -->
<PgDumpPath>C:\Program Files\PostgreSQL\16\bin\pg_dump.exe</PgDumpPath>
<PgRestorePath>C:\Program Files\PostgreSQL\16\bin\pg_restore.exe</PgRestorePath>
<!-- Backup format and compression -->
<BackupFormat>custom</BackupFormat>
<CompressionLevel>6</CompressionLevel>
<IncludeBlobs>true</IncludeBlobs>
<!-- Performance and timeout -->
<TimeoutSeconds>3600</TimeoutSeconds>
<VerboseOutput>true</VerboseOutput>
<!-- Optional: Parallel processing for directory format -->
<!-- <ParallelJobs>4</ParallelJobs> -->
<!-- Optional: Additional pg_dump arguments -->
<!-- <AdditionalArguments>--exclude-table-data=public.temp_data</AdditionalArguments> -->
</BackupOptions>
</DatabaseConfigurationOptions>
```
## Usage in Code
The backup service will automatically read these settings from `database.xml`:
```csharp
// Inject the service
public class MyClass
{
private readonly PostgresBackupService _backupService;
public MyClass(PostgresBackupService backupService)
{
_backupService = backupService;
}
// Create a backup
public async Task CreateBackup()
{
var backupPath = await _backupService.CreateBackupAsync("/path/to/backups");
Console.WriteLine($"Backup created: {backupPath}");
}
// Restore a backup
public async Task RestoreBackup(string backupFile)
{
await _backupService.RestoreBackupAsync(backupFile);
Console.WriteLine("Backup restored successfully");
}
}
```
## Troubleshooting
### "pg_dump executable not found"
1. Verify PostgreSQL is installed
2. Check that `pg_dump` is in your system PATH, or
3. Specify the full path in `<PgDumpPath>` configuration
### Permission Issues
- Ensure the user running Jellyfin has execute permissions for `pg_dump`
- Verify database credentials have backup privileges
### Backup Takes Too Long
- Increase `<TimeoutSeconds>` value
- Consider using `<ParallelJobs>` with directory format for large databases
- Reduce `<CompressionLevel>` for faster backups
### Restore Fails
- Verify the backup file format matches what pg_restore expects
- Check that the target database exists and is accessible
- Review logs for specific error messages
## Security Notes
1. **Password Storage**: The connection string in `database.xml` contains the database password. Ensure this file has appropriate permissions (readable only by the Jellyfin user).
2. **PGPASSWORD**: The service uses the `PGPASSWORD` environment variable to pass credentials to pg_dump, which is more secure than command-line arguments.
3. **Backup Files**: Backup files contain your entire database. Store them securely and restrict access appropriately.
@@ -0,0 +1,481 @@
// <copyright file="PostgresBackupService.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
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.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.DbConfiguration;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
/// <summary>
/// Service for creating and restoring PostgreSQL database backups using pg_dump/pg_restore.
/// </summary>
public class PostgresBackupService
{
private readonly ILogger<PostgresBackupService> _logger;
private readonly IConfigurationManager _configurationManager;
/// <summary>
/// Initializes a new instance of the <see cref="PostgresBackupService"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="configurationManager">The configuration manager.</param>
public PostgresBackupService(
ILogger<PostgresBackupService> logger,
IConfigurationManager configurationManager)
{
_logger = logger;
_configurationManager = configurationManager;
}
/// <summary>
/// Creates a backup of the PostgreSQL database.
/// </summary>
/// <param name="backupDirectory">The directory to save the backup file.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The path to the created backup file.</returns>
public async Task<string> CreateBackupAsync(string backupDirectory, CancellationToken cancellationToken = default)
{
var dbConfig = _configurationManager.GetConfiguration<DatabaseConfigurationOptions>("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);
// 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;
}
/// <summary>
/// Restores a PostgreSQL database from a backup file.
/// </summary>
/// <param name="backupFilePath">The path to the backup file.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the restore operation.</returns>
public async Task RestoreBackupAsync(string backupFilePath, CancellationToken cancellationToken = default)
{
var dbConfig = _configurationManager.GetConfiguration<DatabaseConfigurationOptions>("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<string, string> ParseConnectionString(string connectionString)
{
var result = new Dictionary<string, string>(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<string> BuildPgDumpArguments(
Dictionary<string, string> connectionParams,
DatabaseBackupOptions options,
string outputPath)
{
var args = new List<string>();
// 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<string> BuildPgRestoreArguments(
Dictionary<string, string> connectionParams,
DatabaseBackupOptions options,
string backupPath)
{
var args = new List<string>();
// 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"
};
}
}
@@ -9,6 +9,7 @@ namespace Jellyfin.Database.Providers.Postgres;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -29,16 +30,24 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
{
private readonly IApplicationPaths applicationPaths;
private readonly ILogger<PostgresDatabaseProvider> logger;
private readonly IConfigurationManager? configurationManager;
private PostgresBackupService? backupService;
private string? currentHost;
/// <summary>
/// Initializes a new instance of the <see cref="PostgresDatabaseProvider"/> class.
/// </summary>
/// <param name="applicationPaths">Service to construct the fallback when the old data path configuration is used.</param>
/// <param name="logger">A logger.</param>
public PostgresDatabaseProvider(IApplicationPaths applicationPaths, ILogger<PostgresDatabaseProvider> logger)
/// <param name="configurationManager">The configuration manager (optional, for backup support).</param>
public PostgresDatabaseProvider(
IApplicationPaths applicationPaths,
ILogger<PostgresDatabaseProvider> logger,
IConfigurationManager? configurationManager = null)
{
this.applicationPaths = applicationPaths;
this.logger = logger;
this.configurationManager = configurationManager;
}
/// <summary>
@@ -118,6 +127,9 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
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}",
@@ -129,6 +141,19 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
connectionBuilder.MaxPoolSize,
connectionBuilder.Multiplexing);
// Initialize backup service if host is local and configuration manager is available
if (IsLocalHost(currentHost) && configurationManager is not null)
{
var loggerFactory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance;
var backupLogger = loggerFactory.CreateLogger<PostgresBackupService>();
backupService = new PostgresBackupService(backupLogger, configurationManager);
logger.LogInformation("PostgreSQL backup service initialized for local database (will use external pg_dump/pg_restore tools)");
}
else if (!IsLocalHost(currentHost))
{
logger.LogInformation("PostgreSQL database is on remote server ({Host}). Backup operations via external pg_dump/pg_restore tools are disabled", currentHost);
}
options
.UseNpgsql(
connectionString,
@@ -394,28 +419,134 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
/// <inheritdoc />
public async Task<string> 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 (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. Use pg_dump for proper backups.");
// Return a key for compatibility, but actual backup should be done externally
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);
}
/// <inheritdoc />
public Task RestoreBackupFast(string key, CancellationToken cancellationToken)
public async Task RestoreBackupFast(string key, CancellationToken cancellationToken)
{
logger.LogWarning("Fast restore is not implemented for PostgreSQL. Use pg_restore for proper restoration.");
return Task.CompletedTask;
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.");
}
/// <inheritdoc />
public Task DeleteBackup(string key)
{
logger.LogWarning("Backup deletion is not implemented for PostgreSQL. Manage backups externally.");
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;
}
/// <summary>
/// Determines if the given host is localhost.
/// </summary>
/// <param name="host">The host to check.</param>
/// <returns>True if the host is localhost or 127.0.0.1, false otherwise.</returns>
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
}
/// <inheritdoc/>
public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames)
{