feat(db): add opt-in PostgreSQL database provider
Multi-replica deployments cannot share a SQLite file, so the database has to move to a server engine before the rest of the HA work is usable. - add Jellyfin.Database.Providers.PostgreSQL with an EF Core Npgsql provider - register the provider and a pooled NpgsqlDataSource when DatabaseType is Jellyfin-PostgreSQL - accept postgresql:// URIs and POSTGRES_CONNECTION_STRING alongside CustomProviderOptions - add container-backed provider, CRUD, concurrency and migration tests
This commit is contained in:
@@ -46,6 +46,7 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
@@ -53,6 +54,8 @@
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
<PackageVersion Include="NEbml" Version="1.1.0.5" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageVersion Include="PDFtoImage" Version="5.2.1" />
|
||||
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
@@ -80,6 +83,7 @@
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.15.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.16.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.7.0" />
|
||||
|
||||
@@ -6,12 +6,14 @@ using System.Reflection;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.PostgreSQL;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.Database.Implementations.IJellyfinDatabaseProvider>;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Extensions;
|
||||
@@ -24,6 +26,13 @@ public static class ServiceCollectionExtensions
|
||||
private static IEnumerable<Type> DatabaseProviderTypes()
|
||||
{
|
||||
yield return typeof(SqliteDatabaseProvider);
|
||||
yield return typeof(PostgreSqlDatabaseProvider);
|
||||
}
|
||||
|
||||
private static int GetPoolOption(IEnumerable<CustomDatabaseOption>? options, string key, int defaultValue)
|
||||
{
|
||||
var value = options?.FirstOrDefault(o => o.Key.Equals(key, StringComparison.OrdinalIgnoreCase))?.Value;
|
||||
return int.TryParse(value, out var parsed) ? parsed : defaultValue;
|
||||
}
|
||||
|
||||
private static IDictionary<string, JellyfinDbProviderFactory> GetSupportedDbProviders()
|
||||
@@ -123,6 +132,50 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
serviceCollection.AddSingleton<IJellyfinDatabaseProvider>(providerFactory!);
|
||||
|
||||
if (efCoreConfiguration.DatabaseType.Equals("Jellyfin-PostgreSQL", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
serviceCollection.AddSingleton<NpgsqlDataSource>(static sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<IServerConfigurationManager>().GetConfiguration<DatabaseConfigurationOptions>("database");
|
||||
var options = config.CustomProviderOptions?.Options;
|
||||
|
||||
var connectionString =
|
||||
Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
|
||||
?? options
|
||||
?.FirstOrDefault(o => o.Key.Equals("ConnectionString", StringComparison.OrdinalIgnoreCase))
|
||||
?.Value
|
||||
?? config.CustomProviderOptions?.ConnectionString
|
||||
?? throw new InvalidOperationException(
|
||||
"No PostgreSQL connection string found. Set the POSTGRES_CONNECTION_STRING environment variable, " +
|
||||
"or provide it via CustomProviderOptions.Options[\"ConnectionString\"] or CustomProviderOptions.ConnectionString.");
|
||||
|
||||
// Support postgresql:// / postgres:// URI format (e.g. DATABASE_URL convention).
|
||||
// NpgsqlDataSourceBuilder requires ADO.NET key=value format; convert if needed.
|
||||
if (connectionString.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase)
|
||||
|| connectionString.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var uri = new Uri(connectionString);
|
||||
var userInfoParts = uri.UserInfo.Split(':', 2);
|
||||
connectionString = new NpgsqlConnectionStringBuilder
|
||||
{
|
||||
Host = uri.Host,
|
||||
Port = uri.Port > 0 ? uri.Port : 5432,
|
||||
Database = uri.AbsolutePath.TrimStart('/'),
|
||||
Username = userInfoParts.Length > 0 ? Uri.UnescapeDataString(userInfoParts[0]) : null,
|
||||
Password = userInfoParts.Length > 1 ? Uri.UnescapeDataString(userInfoParts[1]) : null,
|
||||
}.ToString();
|
||||
}
|
||||
|
||||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
|
||||
|
||||
dataSourceBuilder.ConnectionStringBuilder.MinPoolSize = GetPoolOption(options, "MinPoolSize", 2);
|
||||
dataSourceBuilder.ConnectionStringBuilder.MaxPoolSize = GetPoolOption(options, "MaxPoolSize", 20);
|
||||
dataSourceBuilder.ConnectionStringBuilder.CommandTimeout = GetPoolOption(options, "CommandTimeout", 30);
|
||||
|
||||
return dataSourceBuilder.Build();
|
||||
});
|
||||
}
|
||||
|
||||
switch (efCoreConfiguration.LockingBehavior)
|
||||
{
|
||||
case DatabaseLockingBehaviorTypes.NoLock:
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
|
||||
<ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
|
||||
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
|
||||
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
|
||||
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -304,6 +305,10 @@ namespace Jellyfin.Server
|
||||
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
|
||||
.AddSingleton<IApplicationPaths>(appPaths)
|
||||
.AddSingleton<ServerApplicationPaths>(appPaths)
|
||||
// Required by the NpgsqlDataSource factory in AddJellyfinDbContext when
|
||||
// DatabaseType=Jellyfin-PostgreSQL — the factory resolves this from DI
|
||||
// to read CustomProviderOptions and pool settings.
|
||||
.AddSingleton<IServerConfigurationManager>(startupConfigurationManager)
|
||||
.RegisterStartupLogger();
|
||||
|
||||
var startupService = migrationStartupServiceProvider.BuildServiceProvider();
|
||||
|
||||
@@ -68,6 +68,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Tests", "te
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Integration.Tests", "tests\Jellyfin.Server.Integration.Tests\Jellyfin.Server.Integration.Tests.csproj", "{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Database.Tests.PostgreSQL", "tests\Jellyfin.Database.Tests.PostgreSQL\Jellyfin.Database.Tests.PostgreSQL.csproj", "{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Providers.Tests", "tests\Jellyfin.Providers.Tests\Jellyfin.Providers.Tests.csproj", "{A964008C-2136-4716-B6CB-B3426C22320A}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}"
|
||||
@@ -95,6 +97,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jellyfin.Database", "Jellyf
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Sqlite", "src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj", "{A5590358-33CC-4B39-BDE7-DC62FEB03C76}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.PostgreSQL", "src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj", "{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
|
||||
@@ -219,6 +223,10 @@ Global
|
||||
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A964008C-2136-4716-B6CB-B3426C22320A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@@ -259,6 +267,10 @@ Global
|
||||
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@@ -290,6 +302,7 @@ Global
|
||||
{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
{3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
{A964008C-2136-4716-B6CB-B3426C22320A} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
{750B8757-BE3D-4F8C-941A-FBAD94904ADA} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||
{332A5C7A-F907-47CA-910E-BE6F7371B9E0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
@@ -301,6 +314,7 @@ Global
|
||||
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||
{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||
{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\..\SharedVersion.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
|
||||
<ProjectReference Include="..\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+1774
File diff suppressed because it is too large
Load Diff
+1249
File diff suppressed because it is too large
Load Diff
+1771
File diff suppressed because it is too large
Load Diff
+112
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace Jellyfin.Database.Providers.PostgreSQL;
|
||||
|
||||
/// <summary>
|
||||
/// Configures Jellyfin to use a PostgreSQL database.
|
||||
/// </summary>
|
||||
[JellyfinDatabaseProviderKey("Jellyfin-PostgreSQL")]
|
||||
public sealed class PostgreSqlDatabaseProvider : IJellyfinDatabaseProvider
|
||||
{
|
||||
// Sentinel returned by MigrationBackupFast to signal that no file backup was
|
||||
// created (PostgreSQL backups are handled externally by jellyfin-pg-backup CronJob).
|
||||
private const string NoAutomatedBackupKey = "postgresql-no-automated-backup";
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgreSqlDatabaseProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dataSource">The <see cref="NpgsqlDataSource"/> used for PostgreSQL connections.</param>
|
||||
public PostgreSqlDatabaseProvider(NpgsqlDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration)
|
||||
{
|
||||
options.UseNpgsql(
|
||||
_dataSource,
|
||||
o => o.MigrationsAssembly(GetType().Assembly.FullName));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task RunScheduledOptimisation(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RunShutdownTask(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<string> MigrationBackupFast(CancellationToken cancellationToken)
|
||||
{
|
||||
// PostgreSQL pre-migration backups are handled externally by the
|
||||
// jellyfin-pg-backup CronJob. Return a sentinel so callers know no
|
||||
// file backup was created and the migration can proceed safely.
|
||||
return Task.FromResult(NoAutomatedBackupKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RestoreBackupFast(string key, CancellationToken cancellationToken)
|
||||
{
|
||||
// No automated backup was taken; nothing to restore.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task DeleteBackup(string key)
|
||||
{
|
||||
// No automated backup was taken; nothing to delete.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tableNames);
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync("SET session_replication_role = 'replica'").ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
foreach (var tableName in tableNames)
|
||||
{
|
||||
var truncateSql = "TRUNCATE TABLE \"" + tableName + "\" CASCADE";
|
||||
await dbContext.Database.ExecuteSqlRawAsync(truncateSql).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await dbContext.Database.ExecuteSqlRawAsync("SET session_replication_role = 'origin'").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
|
||||
namespace Jellyfin.Database.Providers.PostgreSQL;
|
||||
|
||||
/// <summary>
|
||||
/// The design time factory for <see cref="JellyfinDbContext"/> using PostgreSQL.
|
||||
/// This is only used for the creation of migrations and not during runtime.
|
||||
/// </summary>
|
||||
internal sealed class PostgreSqlDesignTimeJellyfinDbFactory : IDesignTimeDbContextFactory<JellyfinDbContext>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public JellyfinDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString =
|
||||
Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
|
||||
?? "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres";
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
|
||||
|
||||
// Build a NpgsqlDataSource for EF Core configuration. The DI-owned singleton data source
|
||||
// is not available in design-time context; this instance is intentionally not disposed here
|
||||
// because EF Core holds a reference to it for the lifetime of the returned context.
|
||||
// As a design-time-only factory (used only for dotnet-ef CLI operations), the process
|
||||
// exits after the migration is applied, which releases all resources.
|
||||
#pragma warning disable CA2000 // Dispose objects before losing scope
|
||||
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
||||
#pragma warning restore CA2000 // Dispose objects before losing scope
|
||||
optionsBuilder.UseNpgsql(dataSource, o => o.MigrationsAssembly(GetType().Assembly));
|
||||
|
||||
return new JellyfinDbContext(
|
||||
optionsBuilder.Options,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new PostgreSqlDatabaseProvider(dataSource),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
|
||||
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.PostgreSQL;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Database.Tests.PostgreSQL;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that verify concurrent access patterns against a real PostgreSQL 16 container.
|
||||
/// </summary>
|
||||
[Xunit.Trait("Category", "RequiresDocker")]
|
||||
public sealed class PostgreSqlConcurrencyTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
private NpgsqlDataSource? _dataSource;
|
||||
private PostgreSqlDatabaseProvider? _provider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgreSqlConcurrencyTests"/> class.
|
||||
/// </summary>
|
||||
public PostgreSqlConcurrencyTests()
|
||||
{
|
||||
_container = new PostgreSqlBuilder("postgres:16-alpine")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the PostgreSQL container and applies migrations before any tests in the class run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync().ConfigureAwait(false);
|
||||
|
||||
_dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
|
||||
_provider = new PostgreSqlDatabaseProvider(_dataSource);
|
||||
|
||||
// Apply migrations once for the whole test class.
|
||||
var context = CreateContext();
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
await context.Database.MigrateAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and removes the PostgreSQL container after all tests in the class have run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_dataSource is not null)
|
||||
{
|
||||
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that concurrent inserts on <see cref="ActivityLog"/> from four parallel tasks succeed without deadlock.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConcurrentInserts_ActivityLogs_SucceedWithoutDeadlock()
|
||||
{
|
||||
const int parallelTasks = 4;
|
||||
const int insertsPerTask = 10;
|
||||
|
||||
var tasks = new List<Task>(parallelTasks);
|
||||
for (var i = 0; i < parallelTasks; i++)
|
||||
{
|
||||
var taskIndex = i;
|
||||
tasks.Add(Task.Run(
|
||||
() => InsertBatchAsync(taskIndex, insertsPerTask),
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Verify all rows were inserted
|
||||
var verifyCtx = CreateContext();
|
||||
await using (verifyCtx)
|
||||
{
|
||||
var count = await verifyCtx.ActivityLogs
|
||||
.CountAsync(l => l.Type == "ConcurrencyTest", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(parallelTasks * insertsPerTask, count);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InsertBatchAsync(int taskIndex, int insertsPerTask)
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx.ConfigureAwait(false))
|
||||
{
|
||||
for (var j = 0; j < insertsPerTask; j++)
|
||||
{
|
||||
ctx.ActivityLogs.Add(new ActivityLog(
|
||||
$"Task {taskIndex} Insert {j}",
|
||||
"ConcurrencyTest",
|
||||
Guid.Empty));
|
||||
}
|
||||
|
||||
await ctx.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateContext()
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
|
||||
_provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
|
||||
return new JellyfinDbContext(
|
||||
optionsBuilder.Options,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
_provider,
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.PostgreSQL;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Database.Tests.PostgreSQL;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that validate PostgreSQL migrations against a real container.
|
||||
/// </summary>
|
||||
[Xunit.Trait("Category", "RequiresDocker")]
|
||||
public sealed class PostgreSqlMigrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgreSqlMigrationTests"/> class.
|
||||
/// </summary>
|
||||
public PostgreSqlMigrationTests()
|
||||
{
|
||||
_container = new PostgreSqlBuilder("postgres:16-alpine")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the PostgreSQL container before any tests in the class run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and removes the PostgreSQL container after all tests in the class have run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the <c>InitialPostgreSql</c> migration applies cleanly to a fresh PostgreSQL 16 container.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MigrateAsync_AppliesInitialMigrationCleanly()
|
||||
{
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
|
||||
var context = CreateContext(dataSource);
|
||||
await using (context)
|
||||
{
|
||||
await context.Database.MigrateAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var pendingMigrations = await context.Database.GetPendingMigrationsAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Empty(pendingMigrations);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that no pending model changes exist for the PostgreSQL provider,
|
||||
/// acting as a CI gate that fails when model changes are added without a corresponding migration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CheckForUnappliedMigrations_PostgreSql()
|
||||
{
|
||||
// Use a dummy connection string; HasPendingModelChanges() is a purely in-memory check
|
||||
// that compares the current compiled model with the migration snapshots — no real DB needed.
|
||||
const string dummyConnectionString = "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres";
|
||||
using var dataSource = new NpgsqlDataSourceBuilder(dummyConnectionString).Build();
|
||||
using var context = CreateContext(dataSource);
|
||||
|
||||
Assert.False(
|
||||
context.Database.HasPendingModelChanges(),
|
||||
"There are unapplied changes to the EFCore model for PostgreSQL. Please create a Migration.");
|
||||
}
|
||||
|
||||
private static JellyfinDbContext CreateContext(NpgsqlDataSource dataSource)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
|
||||
var provider = new PostgreSqlDatabaseProvider(dataSource);
|
||||
provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
|
||||
return new JellyfinDbContext(
|
||||
optionsBuilder.Options,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
provider,
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.PostgreSQL;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Database.Tests.PostgreSQL;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for CRUD operations, optimisation, and purge against a real PostgreSQL 16 container.
|
||||
/// </summary>
|
||||
[Xunit.Trait("Category", "RequiresDocker")]
|
||||
public sealed class PostgreSqlProviderTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
private NpgsqlDataSource? _dataSource;
|
||||
private PostgreSqlDatabaseProvider? _provider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgreSqlProviderTests"/> class.
|
||||
/// </summary>
|
||||
public PostgreSqlProviderTests()
|
||||
{
|
||||
_container = new PostgreSqlBuilder("postgres:16-alpine")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the PostgreSQL container and applies migrations before any tests in the class run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync().ConfigureAwait(false);
|
||||
|
||||
_dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
|
||||
_provider = new PostgreSqlDatabaseProvider(_dataSource);
|
||||
|
||||
// Apply migrations once for the whole test class.
|
||||
var context = CreateContext();
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
await context.Database.MigrateAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and removes the PostgreSQL container after all tests in the class have run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_dataSource is not null)
|
||||
{
|
||||
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Create/Read/Update/Delete operations on <see cref="User"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Crud_User()
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx)
|
||||
{
|
||||
// Create
|
||||
var user = new User("testuser", "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider");
|
||||
ctx.Users.Add(user);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var userId = user.Id;
|
||||
|
||||
// Read
|
||||
var read = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(read);
|
||||
Assert.Equal("testuser", read.Username);
|
||||
|
||||
// Update
|
||||
read.Username = "updateduser";
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var updated = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
|
||||
Assert.Equal("updateduser", updated!.Username);
|
||||
|
||||
// Delete
|
||||
ctx.Users.Remove(updated);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var deleted = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Create/Read/Update/Delete operations on <see cref="ActivityLog"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Crud_ActivityLog()
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx)
|
||||
{
|
||||
// Create
|
||||
var log = new ActivityLog("Test activity", "TestType", Guid.Empty);
|
||||
ctx.ActivityLogs.Add(log);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var logId = log.Id;
|
||||
|
||||
// Read
|
||||
var read = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(read);
|
||||
Assert.Equal("Test activity", read.Name);
|
||||
|
||||
// Update
|
||||
read.Overview = "Updated overview";
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var updated = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
|
||||
Assert.Equal("Updated overview", updated!.Overview);
|
||||
|
||||
// Delete
|
||||
ctx.ActivityLogs.Remove(updated);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var deleted = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Create/Read/Update/Delete operations on <see cref="DisplayPreferences"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Crud_DisplayPreferences()
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var itemId = Guid.NewGuid();
|
||||
|
||||
// Create
|
||||
var prefs = new DisplayPreferences(userId, itemId, "TestClient");
|
||||
ctx.DisplayPreferences.Add(prefs);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var prefsId = prefs.Id;
|
||||
|
||||
// Read
|
||||
var read = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(read);
|
||||
Assert.Equal("TestClient", read.Client);
|
||||
|
||||
// Update
|
||||
read.ShowSidebar = true;
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var updated = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
|
||||
Assert.True(updated!.ShowSidebar);
|
||||
|
||||
// Delete
|
||||
ctx.DisplayPreferences.Remove(updated);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var deleted = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Create/Read/Update/Delete operations on <see cref="BaseItemEntity"/>, <see cref="Chapter"/>, and <see cref="MediaStreamInfo"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Crud_BaseItem_Chapter_MediaStream()
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx)
|
||||
{
|
||||
var itemId = Guid.NewGuid();
|
||||
|
||||
// Create BaseItem
|
||||
var item = new BaseItemEntity { Id = itemId, Type = "Movie", Name = "Test Movie" };
|
||||
ctx.BaseItems.Add(item);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Create Chapter linked to BaseItem
|
||||
var chapter = new Chapter { ItemId = itemId, Item = item, ChapterIndex = 0, StartPositionTicks = 0, Name = "Intro" };
|
||||
ctx.Chapters.Add(chapter);
|
||||
|
||||
// Create MediaStreamInfo linked to BaseItem
|
||||
var stream = new MediaStreamInfo { ItemId = itemId, Item = item, StreamIndex = 0, StreamType = MediaStreamTypeEntity.Video };
|
||||
ctx.MediaStreamInfos.Add(stream);
|
||||
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Read
|
||||
var readItem = await ctx.BaseItems
|
||||
.Include(i => i.Chapters)
|
||||
.Include(i => i.MediaStreams)
|
||||
.FirstOrDefaultAsync(i => i.Id.Equals(itemId), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(readItem);
|
||||
Assert.Equal("Test Movie", readItem.Name);
|
||||
Assert.Single(readItem.Chapters!);
|
||||
Assert.Single(readItem.MediaStreams!);
|
||||
|
||||
// Update
|
||||
readItem.Name = "Updated Movie";
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var updated = await ctx.BaseItems.FindAsync([itemId], TestContext.Current.CancellationToken);
|
||||
Assert.Equal("Updated Movie", updated!.Name);
|
||||
|
||||
// Delete (cascades to Chapter and MediaStreamInfo)
|
||||
ctx.BaseItems.Remove(updated);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var deleted = await ctx.BaseItems.FindAsync([itemId], TestContext.Current.CancellationToken);
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="PostgreSqlDatabaseProvider.RunScheduledOptimisation"/> executes ANALYZE without error.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScheduledOptimisation_ExecutesWithoutError()
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx)
|
||||
{
|
||||
var factory = new TestDbContextFactory(ctx);
|
||||
_provider!.DbContextFactory = factory;
|
||||
|
||||
await _provider.RunScheduledOptimisation(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="PostgreSqlDatabaseProvider.PurgeDatabase"/> empties tables and resets <c>session_replication_role</c>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PurgeDatabase_EmptiesTablesAndResetsFkRole()
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx)
|
||||
{
|
||||
// Seed a row
|
||||
ctx.ActivityLogs.Add(new ActivityLog("Purge test", "TestType", Guid.Empty));
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(await ctx.ActivityLogs.AnyAsync(TestContext.Current.CancellationToken));
|
||||
|
||||
// Purge
|
||||
await _provider!.PurgeDatabase(ctx, ["ActivityLogs"]);
|
||||
|
||||
// session_replication_role should be reset to 'origin' (default)
|
||||
var role = await ctx.Database
|
||||
.SqlQueryRaw<string>("SELECT current_setting('session_replication_role')")
|
||||
.FirstAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal("origin", role);
|
||||
}
|
||||
|
||||
// Verify table is empty via a fresh context
|
||||
var freshCtx = CreateContext();
|
||||
await using (freshCtx)
|
||||
{
|
||||
Assert.False(await freshCtx.ActivityLogs.AnyAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateContext()
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
|
||||
_provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
|
||||
return new JellyfinDbContext(
|
||||
optionsBuilder.Options,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
_provider,
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A minimal <see cref="IDbContextFactory{TContext}"/> wrapper that returns a pre-existing context.
|
||||
/// </summary>
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<JellyfinDbContext>
|
||||
{
|
||||
private readonly JellyfinDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TestDbContextFactory"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The context to return from <see cref="CreateDbContext"/>.</param>
|
||||
public TestDbContextFactory(JellyfinDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pre-existing <see cref="JellyfinDbContext"/> instance.
|
||||
/// </summary>
|
||||
/// <returns>The pre-existing <see cref="JellyfinDbContext"/> instance.</returns>
|
||||
public JellyfinDbContext CreateDbContext() => _context;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pre-existing <see cref="JellyfinDbContext"/> instance as a completed task.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token (unused).</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> containing the pre-existing <see cref="JellyfinDbContext"/> instance.</returns>
|
||||
public Task<JellyfinDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user