diff --git a/Directory.Build.props b/Directory.Build.props
index e2a3814..ae9e57d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,7 +1,7 @@
- 3.0.2.0
- 3.0.2.0
- 3.0.2.0
+ 3.0.3.0
+ 3.0.3.0
+ 3.0.3.0
diff --git a/Jellyfin.Database.Providers.Postgres/Api/IPluginSchemaHost.cs b/Jellyfin.Database.Providers.Postgres/Api/IPluginSchemaHost.cs
new file mode 100644
index 0000000..d1ae5c5
--- /dev/null
+++ b/Jellyfin.Database.Providers.Postgres/Api/IPluginSchemaHost.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Jellyfin.Database.Providers.Postgres.Api;
+
+///
+/// Provides isolated PostgreSQL storage for a plugin-owned schema.
+///
+///
+/// This contract deliberately does not expose a connection string, an Npgsql connection, or Jellyfin's
+/// public schema. Consumers must use Jellyfin's own contracts for Jellyfin data and use this host
+/// only for state they own. Loaded plugins are trusted in-process code; this API is an isolation boundary
+/// against accidental cross-plugin access, not a security boundary against malicious code.
+///
+public interface IPluginSchemaHost
+{
+ ///
+ /// Opens a named private schema and binds it to its plugin owner.
+ ///
+ /// Schema name and immutable plugin owner.
+ /// Cancellation token.
+ /// An opaque handle for the plugin's schema.
+ Task OpenSchemaAsync(PluginSchemaRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Opens the generated private schema assigned to , creating it when needed.
+ ///
+ /// The immutable identifier of the consuming plugin.
+ /// Cancellation token.
+ /// An opaque handle for the plugin's schema.
+ Task OpenSchemaAsync(Guid pluginId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Claims the immediate pre-registry schema matching the target plugin name and moves it to the
+ /// target private-schema name.
+ ///
+ /// The target schema name and immutable plugin owner.
+ /// Cancellation token.
+ /// The handle of the registered target schema.
+ ///
+ /// The only legacy source accepted is the exact prefix of 's target
+ /// name: name_guid may claim only name. The target must not exist and the source must
+ /// be unregistered. This preserves the source tables by an atomic PostgreSQL schema rename.
+ ///
+ Task MigratePreRegistrySchemaAsync(
+ PluginSchemaRequest request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Applies versioned DDL migrations in the schema represented by .
+ ///
+ /// Schema handle returned by .
+ /// Migrations to apply in their supplied order.
+ /// Cancellation token.
+ /// A task that completes after all migrations have been committed.
+ Task ApplyMigrationsAsync(
+ PluginSchema schema,
+ IReadOnlyList migrations,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Executes one parameterized data command inside the plugin's schema.
+ ///
+ /// Schema handle returned by .
+ /// A single unqualified SQL statement.
+ /// Parameters for .
+ /// Cancellation token.
+ /// The number of rows affected.
+ Task ExecuteAsync(
+ PluginSchema schema,
+ string sql,
+ IReadOnlyList? parameters = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Executes one parameterized query inside the plugin's schema and projects its rows without exposing
+ /// the underlying connection.
+ ///
+ /// Projection result type.
+ /// Schema handle returned by .
+ /// A single unqualified SQL query.
+ /// Synchronous projection of the open reader.
+ /// Parameters for .
+ /// Cancellation token.
+ /// The projected result.
+ Task QueryAsync(
+ PluginSchema schema,
+ string sql,
+ Func project,
+ IReadOnlyList? parameters = null,
+ CancellationToken cancellationToken = default);
+}
diff --git a/Jellyfin.Database.Providers.Postgres/Api/PluginSchema.cs b/Jellyfin.Database.Providers.Postgres/Api/PluginSchema.cs
new file mode 100644
index 0000000..162224c
--- /dev/null
+++ b/Jellyfin.Database.Providers.Postgres/Api/PluginSchema.cs
@@ -0,0 +1,22 @@
+using System;
+
+namespace Jellyfin.Database.Providers.Postgres.Api;
+
+///
+/// Opaque reference to the private PostgreSQL schema assigned to a plugin.
+///
+public sealed class PluginSchema
+{
+ internal PluginSchema(Guid pluginId, string name)
+ {
+ PluginId = pluginId;
+ Name = name;
+ }
+
+ /// Gets the immutable identifier of the schema owner.
+ public Guid PluginId { get; }
+
+ /// Gets the generated schema name for diagnostics only.
+ /// Consumers must not use this value to build SQL.
+ public string Name { get; }
+}
diff --git a/Jellyfin.Database.Providers.Postgres/Api/PluginSchemaMigration.cs b/Jellyfin.Database.Providers.Postgres/Api/PluginSchemaMigration.cs
new file mode 100644
index 0000000..5227804
--- /dev/null
+++ b/Jellyfin.Database.Providers.Postgres/Api/PluginSchemaMigration.cs
@@ -0,0 +1,14 @@
+using System;
+
+namespace Jellyfin.Database.Providers.Postgres.Api;
+
+///
+/// One immutable, plugin-owned schema migration.
+///
+/// Stable migration identifier, unique within the plugin.
+/// One unqualified DDL statement to execute.
+public sealed record PluginSchemaMigration(string Id, string Sql)
+{
+ /// Gets a deterministic checksum used to detect modified applied migrations.
+ public string Checksum { get; } = PluginSchemaMigrationChecksum.Compute(Id, Sql);
+}
diff --git a/Jellyfin.Database.Providers.Postgres/Api/PluginSchemaMigrationChecksum.cs b/Jellyfin.Database.Providers.Postgres/Api/PluginSchemaMigrationChecksum.cs
new file mode 100644
index 0000000..a721299
--- /dev/null
+++ b/Jellyfin.Database.Providers.Postgres/Api/PluginSchemaMigrationChecksum.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Jellyfin.Database.Providers.Postgres.Api;
+
+internal static class PluginSchemaMigrationChecksum
+{
+ internal static string Compute(string id, string sql)
+ {
+ ArgumentNullException.ThrowIfNull(id);
+ ArgumentNullException.ThrowIfNull(sql);
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(string.Concat(id, "\n", sql)));
+ return Convert.ToHexString(bytes);
+ }
+}
diff --git a/Jellyfin.Database.Providers.Postgres/Api/PluginSchemaRequest.cs b/Jellyfin.Database.Providers.Postgres/Api/PluginSchemaRequest.cs
new file mode 100644
index 0000000..7049b71
--- /dev/null
+++ b/Jellyfin.Database.Providers.Postgres/Api/PluginSchemaRequest.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace Jellyfin.Database.Providers.Postgres.Api;
+
+///
+/// Identifies a plugin-owned PostgreSQL schema.
+///
+/// Immutable identifier of the plugin that owns the schema.
+/// Stable lowercase PostgreSQL schema name, for example jellytrend.
+/// Whether an existing unclaimed schema may be bound to .
+///
+/// Adoption exists only for a deliberate migration from a predecessor contract. Once claimed, the host
+/// writes an ownership marker and rejects future requests by a different plugin identifier.
+///
+public sealed record PluginSchemaRequest(Guid PluginId, string SchemaName, bool AdoptExistingSchema = false);
diff --git a/Jellyfin.Database.Providers.Postgres/Api/PluginSqlParameter.cs b/Jellyfin.Database.Providers.Postgres/Api/PluginSqlParameter.cs
new file mode 100644
index 0000000..f91371a
--- /dev/null
+++ b/Jellyfin.Database.Providers.Postgres/Api/PluginSqlParameter.cs
@@ -0,0 +1,8 @@
+namespace Jellyfin.Database.Providers.Postgres.Api;
+
+///
+/// A named value bound to a private-schema SQL statement.
+///
+/// Parameter name without SQL syntax, for example itemId.
+/// Value to bind. A null value is sent as .
+public sealed record PluginSqlParameter(string Name, object? Value);
diff --git a/Jellyfin.Database.Providers.Postgres/Api/PostgresSchemaHostCapability.cs b/Jellyfin.Database.Providers.Postgres/Api/PostgresSchemaHostCapability.cs
new file mode 100644
index 0000000..fdee80e
--- /dev/null
+++ b/Jellyfin.Database.Providers.Postgres/Api/PostgresSchemaHostCapability.cs
@@ -0,0 +1,23 @@
+namespace Jellyfin.Database.Providers.Postgres.Api;
+
+///
+/// Public, dependency-free marker for discovering the private-schema host from another Jellyfin plugin.
+///
+///
+/// A consumer obtains by asking Jellyfin's service provider for that
+/// interface type from this loaded assembly. In a plugin load-context mismatch, consumers should first
+/// locate PG Provider's live plugin assembly through IPluginManager.Plugins, then request the
+/// type named by from that assembly. Consumers must not construct an
+/// internal provider implementation through reflection or ActivatorUtilities.
+///
+public static class PostgresSchemaHostCapability
+{
+ /// Stable protocol identifier used by reflection-based consumers.
+ public const string ProtocolName = "jellyfin.postgres.private-schema-host";
+
+ /// Current backwards-compatible protocol version.
+ public const int ProtocolVersion = 1;
+
+ /// Full name of the service contract to request from Jellyfin DI.
+ public const string ContractTypeName = "Jellyfin.Database.Providers.Postgres.Api.IPluginSchemaHost";
+}
diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/Models/PluginSchemaMaintenancePolicyRequest.cs b/Jellyfin.Database.Providers.Postgres/Controllers/Models/PluginSchemaMaintenancePolicyRequest.cs
new file mode 100644
index 0000000..7ca1a27
--- /dev/null
+++ b/Jellyfin.Database.Providers.Postgres/Controllers/Models/PluginSchemaMaintenancePolicyRequest.cs
@@ -0,0 +1,14 @@
+namespace Jellyfin.Database.Providers.Postgres.Controllers.Models;
+
+/// Administrator-selected scheduled-maintenance policy for a private plugin schema.
+public sealed class PluginSchemaMaintenancePolicyRequest
+{
+ /// Gets or sets a value indicating whether scheduled provider maintenance refreshes planner statistics.
+ public bool IncludeAnalyze { get; set; }
+
+ /// Gets or sets a value indicating whether scheduled provider maintenance runs VACUUM (ANALYZE).
+ public bool IncludeVacuum { get; set; }
+
+ /// Gets or sets a value indicating whether scheduled provider maintenance rebuilds indexes concurrently.
+ public bool IncludeReindex { get; set; }
+}
diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs b/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs
index b4ebeaf..331aefd 100644
--- a/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs
+++ b/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs
@@ -15,10 +15,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
+#pragma warning disable SA1611, SA1615
+
namespace Jellyfin.Database.Providers.Postgres.Controllers;
///
@@ -39,6 +42,8 @@ public class PostgresController : ControllerBase
private readonly MaintenanceService _maintenanceService;
private readonly InstantSearchService _instantSearch;
private readonly ExportToSqliteService _exportService;
+ private readonly PluginSchemaExplorerService _pluginSchemaExplorer;
+ private readonly PluginSchemaMaintenanceService _pluginSchemaMaintenance;
private readonly ILogger _logger;
///
@@ -50,6 +55,8 @@ public class PostgresController : ControllerBase
/// Maintenance service instance.
/// Instant search service instance.
/// Export service instance.
+ /// Read-only explorer for registered private plugin schemas.
+ /// Scoped maintenance for registered private plugin schemas.
/// Logger instance.
public PostgresController(
IApplicationPaths appPaths,
@@ -58,6 +65,8 @@ public PostgresController(
MaintenanceService maintenanceService,
InstantSearchService instantSearch,
ExportToSqliteService exportService,
+ PluginSchemaExplorerService pluginSchemaExplorer,
+ PluginSchemaMaintenanceService pluginSchemaMaintenance,
ILogger logger)
{
_appPaths = appPaths;
@@ -66,6 +75,8 @@ public PostgresController(
_maintenanceService = maintenanceService;
_instantSearch = instantSearch;
_exportService = exportService;
+ _pluginSchemaExplorer = pluginSchemaExplorer;
+ _pluginSchemaMaintenance = pluginSchemaMaintenance;
_logger = logger;
}
@@ -79,8 +90,8 @@ public PostgresController(
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult