//
// 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.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 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;
///
/// 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)
{
static T? GetOption(ICollection? options, string key, Func converter, Func? defaultValue = null)
{
if (options is null)
{
return defaultValue is not null ? defaultValue() : default;
}
var value = options.FirstOrDefault(e => e.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
if (value is null)
{
return defaultValue is not null ? defaultValue() : default;
}
return converter(value.Value);
}
var customOptions = databaseConfiguration.CustomProviderOptions?.Options;
var connectionBuilder = new NpgsqlConnectionStringBuilder
{
Host = GetOption(customOptions, "host", e => e, () => "localhost")!,
Port = GetOption(customOptions, "port", int.Parse, () => 5432),
Database = GetOption(customOptions, "database", e => e, () => "jellyfin")!,
Username = GetOption(customOptions, "username", e => e, () => "jellyfin")!,
Password = GetOption(customOptions, "password", e => e, () => string.Empty)!,
Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true),
CommandTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 30),
Timeout = GetOption(customOptions, "connection-timeout", int.Parse, () => 15),
Multiplexing = GetOption(customOptions, "multiplexing", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => false),
MaxPoolSize = GetOption(customOptions, "max-pool-size", int.Parse, () => 100),
MinPoolSize = GetOption(customOptions, "min-pool-size", int.Parse, () => 0)
};
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 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();
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,
npgsqlOptions => npgsqlOptions.MigrationsAssembly(GetType().Assembly.FullName))
.ConfigureWarnings(warnings =>
warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
var enableSensitiveDataLogging = GetOption(customOptions, "EnableSensitiveDataLogging", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => 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)
{
static T? GetOption(ICollection? options, string key, Func converter, Func? defaultValue = null)
{
if (options is null)
{
return defaultValue is not null ? defaultValue() : default;
}
var value = options.FirstOrDefault(e => e.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
if (value is null)
{
return defaultValue is not null ? defaultValue() : default;
}
return converter(value.Value);
}
var customOptions = databaseConfiguration.CustomProviderOptions?.Options;
var host = GetOption(customOptions, "host", e => e, () => "localhost")!;
var port = GetOption(customOptions, "port", int.Parse, () => 5432);
var database = GetOption(customOptions, "database", e => e, () => "jellyfin")!;
var username = GetOption(customOptions, "username", e => e, () => "jellyfin")!;
var password = GetOption(customOptions, "password", e => e, () => string.Empty)!;
var timeout = GetOption(customOptions, "connection-timeout", int.Parse, () => 15);
// 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
{
logger.LogInformation("Checking PostgreSQL database for missing tables...");
var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
// Ensure the migrations history table exists
await context.Database.EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
// Get applied migrations
var appliedMigrations = await context.Database.GetAppliedMigrationsAsync(cancellationToken).ConfigureAwait(false);
var appliedMigrationsList = appliedMigrations.ToList();
logger.LogDebug("Found {Count} applied migrations", appliedMigrationsList.Count);
// Get pending migrations
var pendingMigrations = await context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false);
var pendingMigrationsList = pendingMigrations.ToList();
if (pendingMigrationsList.Count > 0)
{
logger.LogInformation("Found {Count} pending migrations: {Migrations}", pendingMigrationsList.Count, string.Join(", ", pendingMigrationsList));
logger.LogInformation("Applying migrations...");
try
{
// Apply all pending migrations
await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
logger.LogInformation("Successfully applied {Count} migrations", pendingMigrationsList.Count);
}
catch (Npgsql.PostgresException pgEx) when (pgEx.SqlState == "42P07") // relation already exists
{
// Table already exists - this can happen if migrations were partially applied
logger.LogWarning("Migration attempted to create existing table. This may indicate a previous failed migration. Error: {Message}", pgEx.Message);
logger.LogWarning("Attempting to mark migration as applied in history table...");
// Try to manually record the migration as applied
try
{
await RecordMigrationAsAppliedAsync(context, pendingMigrationsList, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Successfully recovered from partial migration state");
}
catch (Exception recordEx)
{
logger.LogError(recordEx, "Failed to recover from partial migration. Manual intervention may be required.");
throw;
}
}
catch (InvalidOperationException ex) when (ex.Message.Contains("pending changes", StringComparison.OrdinalIgnoreCase))
{
// This can happen if the model has changed but migrations haven't been generated
logger.LogWarning("Model has pending changes. This may indicate missing migration files. Error: {Message}", ex.Message);
logger.LogWarning("If you're seeing this on a test/production system, ensure all migration files are deployed.");
throw;
}
}
else
{
logger.LogInformation("All database tables are up to date. No migrations needed.");
}
// Verify all expected tables exist in each schema
var connection = context.Database.GetDbConnection();
var wasOpened = false;
try
{
if (connection.State != System.Data.ConnectionState.Open)
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
wasOpened = true;
}
foreach (var schema in Schemas.All)
{
// Count tables in the schema
var countQuery = @"
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = @schema AND table_type = 'BASE TABLE'";
await using (var command = connection.CreateCommand())
{
command.CommandText = countQuery;
var parameter = command.CreateParameter();
parameter.ParameterName = "@schema";
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 migration.", schema);
}
}
}
logger.LogInformation("Database table verification complete");
}
finally
{
if (wasOpened)
{
await connection.CloseAsync().ConfigureAwait(false);
}
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to ensure PostgreSQL tables exist. Error: {Message}", ex.Message);
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 (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);
}
}