1061 lines
47 KiB
C#
1061 lines
47 KiB
C#
// <copyright file="PostgresDatabaseProvider.cs" company="PlaceholderCompany">
|
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
|
// </copyright>
|
|
|
|
#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;
|
|
|
|
/// <summary>
|
|
/// Configures Jellyfin to use a PostgreSQL database.
|
|
/// </summary>
|
|
[JellyfinDatabaseProviderKey("Jellyfin-PostgreSQL")]
|
|
public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
|
|
{
|
|
private readonly IApplicationPaths applicationPaths;
|
|
private readonly ILogger<PostgresDatabaseProvider> logger;
|
|
private readonly IConfigurationManager? configurationManager;
|
|
private PostgresBackupService? backupService;
|
|
private string? currentHost;
|
|
private DatabaseConfigurationOptions? databaseConfig;
|
|
|
|
/// <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>
|
|
/// <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>
|
|
/// Schema names mapping to legacy database files.
|
|
/// </summary>
|
|
public static class Schemas
|
|
{
|
|
/// <summary>
|
|
/// Schema for activity log tables (legacy: activitylog.db).
|
|
/// </summary>
|
|
public const string ActivityLog = "activitylog";
|
|
|
|
/// <summary>
|
|
/// Schema for authentication tables (legacy: authentication.db).
|
|
/// </summary>
|
|
public const string Authentication = "authentication";
|
|
|
|
/// <summary>
|
|
/// Schema for display preferences tables (legacy: displaypreferences.db).
|
|
/// </summary>
|
|
public const string DisplayPreferences = "displaypreferences";
|
|
|
|
/// <summary>
|
|
/// Schema for library tables (legacy: library.db).
|
|
/// </summary>
|
|
public const string Library = "library";
|
|
|
|
/// <summary>
|
|
/// Schema for user tables (legacy: users.db).
|
|
/// </summary>
|
|
public const string Users = "users";
|
|
|
|
/// <summary>
|
|
/// Gets all schema names.
|
|
/// </summary>
|
|
public static string[] All => [ActivityLog, Authentication, DisplayPreferences, Library, Users];
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; }
|
|
|
|
/// <inheritdoc/>
|
|
public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration)
|
|
{
|
|
// Store the configuration for later use
|
|
databaseConfig = databaseConfiguration;
|
|
|
|
static T? GetOption<T>(ICollection<CustomDatabaseOption>? options, string key, Func<string, T> 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<string>(customOptions, "host", e => e, null);
|
|
if (host is not null)
|
|
{
|
|
connectionBuilder.Host = host;
|
|
}
|
|
|
|
var port = GetOption<int?>(customOptions, "port", e => int.Parse(e), null);
|
|
if (port.HasValue)
|
|
{
|
|
connectionBuilder.Port = port.Value;
|
|
}
|
|
|
|
var database = GetOption<string>(customOptions, "database", e => e, null);
|
|
if (database is not null)
|
|
{
|
|
connectionBuilder.Database = database;
|
|
}
|
|
|
|
var username = GetOption<string>(customOptions, "username", e => e, null);
|
|
if (username is not null)
|
|
{
|
|
connectionBuilder.Username = username;
|
|
}
|
|
|
|
var password = GetOption<string>(customOptions, "password", e => e, null);
|
|
if (password is not null)
|
|
{
|
|
connectionBuilder.Password = password;
|
|
}
|
|
|
|
var pooling = GetOption<bool?>(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), null);
|
|
if (pooling.HasValue)
|
|
{
|
|
connectionBuilder.Pooling = pooling.Value;
|
|
}
|
|
|
|
var commandTimeout = GetOption<int?>(customOptions, "command-timeout", e => int.Parse(e), null);
|
|
if (commandTimeout.HasValue)
|
|
{
|
|
connectionBuilder.CommandTimeout = commandTimeout.Value;
|
|
}
|
|
|
|
var timeout = GetOption<int?>(customOptions, "connection-timeout", e => int.Parse(e), null);
|
|
if (timeout.HasValue)
|
|
{
|
|
connectionBuilder.Timeout = timeout.Value;
|
|
}
|
|
|
|
var multiplexing = GetOption<bool?>(customOptions, "multiplexing", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), null);
|
|
if (multiplexing.HasValue)
|
|
{
|
|
connectionBuilder.Multiplexing = multiplexing.Value;
|
|
}
|
|
|
|
var maxPoolSize = GetOption<int?>(customOptions, "max-pool-size", e => int.Parse(e), null);
|
|
if (maxPoolSize.HasValue)
|
|
{
|
|
connectionBuilder.MaxPoolSize = maxPoolSize.Value;
|
|
}
|
|
|
|
var minPoolSize = GetOption<int?>(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<bool?>(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<PostgresBackupService>();
|
|
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<bool?>(customOptions, "EnableSensitiveDataLogging", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), null) ?? false;
|
|
if (enableSensitiveDataLogging)
|
|
{
|
|
options.EnableSensitiveDataLogging(enableSensitiveDataLogging);
|
|
logger.LogInformation("EnableSensitiveDataLogging is enabled on PostgreSQL connection");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures the PostgreSQL database exists, creating it if necessary.
|
|
/// </summary>
|
|
/// <param name="databaseConfiguration">The database configuration.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures all required schemas exist in the PostgreSQL database.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures all required database tables exist by applying pending migrations.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="context">The database context.</param>
|
|
/// <param name="migrationIds">List of migration IDs to record.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
private async Task RecordMigrationAsAppliedAsync(JellyfinDbContext context, IList<string> 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<System.Reflection.AssemblyInformationalVersionAttribute>()?
|
|
.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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
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<string>();
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc);
|
|
|
|
// Assign entities to schemas based on their legacy database origin
|
|
AssignEntitiesToSchemas(modelBuilder);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assigns entities to PostgreSQL schemas based on their legacy database origin.
|
|
/// </summary>
|
|
/// <param name="modelBuilder">The model builder.</param>
|
|
private static void AssignEntitiesToSchemas(ModelBuilder modelBuilder)
|
|
{
|
|
// ActivityLog schema (legacy: activitylog.db)
|
|
modelBuilder.Entity<ActivityLog>().ToTable("ActivityLogs", Schemas.ActivityLog);
|
|
|
|
// Authentication schema (legacy: authentication.db)
|
|
modelBuilder.Entity<ApiKey>().ToTable("ApiKeys", Schemas.Authentication);
|
|
modelBuilder.Entity<Device>().ToTable("Devices", Schemas.Authentication);
|
|
modelBuilder.Entity<DeviceOptions>().ToTable("DeviceOptions", Schemas.Authentication);
|
|
|
|
// DisplayPreferences schema (legacy: displaypreferences.db)
|
|
modelBuilder.Entity<DisplayPreferences>().ToTable("DisplayPreferences", Schemas.DisplayPreferences);
|
|
modelBuilder.Entity<ItemDisplayPreferences>().ToTable("ItemDisplayPreferences", Schemas.DisplayPreferences);
|
|
modelBuilder.Entity<CustomItemDisplayPreferences>().ToTable("CustomItemDisplayPreferences", Schemas.DisplayPreferences);
|
|
modelBuilder.Entity<HomeSection>().ToTable("HomeSections", Schemas.DisplayPreferences);
|
|
|
|
// Users schema (legacy: users.db)
|
|
modelBuilder.Entity<User>().ToTable("Users", Schemas.Users);
|
|
modelBuilder.Entity<Permission>().ToTable("Permissions", Schemas.Users);
|
|
modelBuilder.Entity<Preference>().ToTable("Preferences", Schemas.Users);
|
|
modelBuilder.Entity<AccessSchedule>().ToTable("AccessSchedules", Schemas.Users);
|
|
|
|
// Library schema (legacy: library.db) - All remaining entities
|
|
modelBuilder.Entity<BaseItemEntity>().ToTable("BaseItems", Schemas.Library);
|
|
modelBuilder.Entity<Chapter>().ToTable("Chapters", Schemas.Library);
|
|
modelBuilder.Entity<MediaStreamInfo>().ToTable("MediaStreamInfos", Schemas.Library);
|
|
modelBuilder.Entity<AttachmentStreamInfo>().ToTable("AttachmentStreamInfos", Schemas.Library);
|
|
modelBuilder.Entity<ImageInfo>().ToTable("ImageInfos", Schemas.Library);
|
|
modelBuilder.Entity<BaseItemImageInfo>().ToTable("BaseItemImageInfos", Schemas.Library);
|
|
modelBuilder.Entity<BaseItemProvider>().ToTable("BaseItemProviders", Schemas.Library);
|
|
modelBuilder.Entity<BaseItemMetadataField>().ToTable("BaseItemMetadataFields", Schemas.Library);
|
|
modelBuilder.Entity<BaseItemTrailerType>().ToTable("BaseItemTrailerTypes", Schemas.Library);
|
|
modelBuilder.Entity<ItemValue>().ToTable("ItemValues", Schemas.Library);
|
|
modelBuilder.Entity<ItemValueMap>().ToTable("ItemValuesMap", Schemas.Library);
|
|
modelBuilder.Entity<People>().ToTable("Peoples", Schemas.Library);
|
|
modelBuilder.Entity<PeopleBaseItemMap>().ToTable("PeopleBaseItemMap", Schemas.Library);
|
|
modelBuilder.Entity<UserData>().ToTable("UserData", Schemas.Library);
|
|
modelBuilder.Entity<AncestorId>().ToTable("AncestorIds", Schemas.Library);
|
|
modelBuilder.Entity<TrickplayInfo>().ToTable("TrickplayInfos", Schemas.Library);
|
|
modelBuilder.Entity<MediaSegment>().ToTable("MediaSegments", Schemas.Library);
|
|
modelBuilder.Entity<KeyframeData>().ToTable("KeyframeData", Schemas.Library);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task RunShutdownTask(CancellationToken cancellationToken)
|
|
{
|
|
if (DbContextFactory is null)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// Clear connection pools
|
|
NpgsqlConnection.ClearAllPools();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
|
{
|
|
// PostgreSQL-specific conventions can be added here if needed
|
|
}
|
|
|
|
/// <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 (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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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.");
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(tableNames);
|
|
|
|
var deleteQueries = new List<string>();
|
|
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);
|
|
}
|
|
}
|