From 4e9b027ea6f1de0462eff792d975be94fb461e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 02:07:07 -0700 Subject: [PATCH 01/15] test: suite xUnit de verificacion y resumen ejecutivo autogenerado Reemplaza el runner casero CompatibilityChecks por un proyecto xUnit estandar (57 pruebas en 7 areas) que cualquiera puede ejecutar con dotnet test: contrato con Jellyfin 12.1, database.xml, auditoria del SQL crudo contra el modelo EF, reescrituras de SQL, logger y pruebas de integracion con PostgreSQL desechable (omitidas si no hay conexion). Anade tools/Generar-ResumenEjecutivo.ps1, que traduce el TRX a docs/VERIFICACION-RESUMEN.md, y el workflow verification.yml que lo publica en CI con un servicio postgres:17. --- .github/workflows/verification.yml | 66 +++ Jellyfin.Database.Providers.Postgres.sln | 22 +- .../Properties/AssemblyInfo.cs | 4 + .../DatabaseConfigurationTests.cs | 88 ++++ ...n.Database.Providers.Postgres.Tests.csproj | 37 ++ .../LoggingTests.cs | 80 ++++ .../PostgresIntegrationTests.cs | 436 ++++++++++++++++++ .../ProviderContractTests.cs | 115 +++++ .../QueryConsoleTests.cs | 79 ++++ .../SchemaAuditTests.cs | 87 ++++ .../SqlRewriteTests.cs | 80 ++++ .../TestEnvironment.cs | 99 ++++ .../TestPaths.cs | 30 ++ tools/Generar-ResumenEjecutivo.ps1 | 181 ++++++++ 14 files changed, 1403 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/verification.yml create mode 100644 Jellyfin.Database.Providers.Postgres/Properties/AssemblyInfo.cs create mode 100644 tests/Jellyfin.Database.Providers.Postgres.Tests/DatabaseConfigurationTests.cs create mode 100644 tests/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj create mode 100644 tests/Jellyfin.Database.Providers.Postgres.Tests/LoggingTests.cs create mode 100644 tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresIntegrationTests.cs create mode 100644 tests/Jellyfin.Database.Providers.Postgres.Tests/ProviderContractTests.cs create mode 100644 tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs create mode 100644 tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs create mode 100644 tests/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs create mode 100644 tests/Jellyfin.Database.Providers.Postgres.Tests/TestEnvironment.cs create mode 100644 tests/Jellyfin.Database.Providers.Postgres.Tests/TestPaths.cs create mode 100644 tools/Generar-ResumenEjecutivo.ps1 diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml new file mode 100644 index 0000000..7506fa7 --- /dev/null +++ b/.github/workflows/verification.yml @@ -0,0 +1,66 @@ +name: Verificación + +# Ejecuta la suite de pruebas del plugin y publica el resumen ejecutivo generado a partir del resultado +# real (docs/VERIFICACION-RESUMEN.md). El resumen queda en la pestaña Summary del job y como artefacto. +on: + push: + branches: + - main + - 'release/**' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + name: Pruebas y resumen ejecutivo + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configurar .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Ejecutar la suite y generar el resumen + shell: pwsh + env: + POSTGRES_TEST_CONNECTION: Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres + run: ./tools/Generar-ResumenEjecutivo.ps1 + + - name: Publicar el resumen en el job + if: always() + shell: bash + run: | + if [ -f docs/VERIFICACION-RESUMEN.md ]; then + cat docs/VERIFICACION-RESUMEN.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Subir artefactos + if: always() + uses: actions/upload-artifact@v4 + with: + name: verificacion + path: | + docs/VERIFICACION-RESUMEN.md + tests/TestResults/*.trx + if-no-files-found: warn diff --git a/Jellyfin.Database.Providers.Postgres.sln b/Jellyfin.Database.Providers.Postgres.sln index 21bb076..3b070a5 100644 --- a/Jellyfin.Database.Providers.Postgres.sln +++ b/Jellyfin.Database.Providers.Postgres.sln @@ -1,9 +1,14 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 + +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Postgres", "Jellyfin.Database.Providers.Postgres\Jellyfin.Database.Providers.Postgres.csproj", "{D4FAA44B-984A-4FB7-810E-EC3315821AE8}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Postgres.Tests", "tests\Jellyfin.Database.Providers.Postgres.Tests\Jellyfin.Database.Providers.Postgres.Tests.csproj", "{B4B57747-951A-4026-985F-772CE174A3AD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,8 +31,23 @@ Global {D4FAA44B-984A-4FB7-810E-EC3315821AE8}.Release|x64.Build.0 = Release|Any CPU {D4FAA44B-984A-4FB7-810E-EC3315821AE8}.Release|x86.ActiveCfg = Release|Any CPU {D4FAA44B-984A-4FB7-810E-EC3315821AE8}.Release|x86.Build.0 = Release|Any CPU + {B4B57747-951A-4026-985F-772CE174A3AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4B57747-951A-4026-985F-772CE174A3AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4B57747-951A-4026-985F-772CE174A3AD}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4B57747-951A-4026-985F-772CE174A3AD}.Debug|x64.Build.0 = Debug|Any CPU + {B4B57747-951A-4026-985F-772CE174A3AD}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4B57747-951A-4026-985F-772CE174A3AD}.Debug|x86.Build.0 = Debug|Any CPU + {B4B57747-951A-4026-985F-772CE174A3AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4B57747-951A-4026-985F-772CE174A3AD}.Release|Any CPU.Build.0 = Release|Any CPU + {B4B57747-951A-4026-985F-772CE174A3AD}.Release|x64.ActiveCfg = Release|Any CPU + {B4B57747-951A-4026-985F-772CE174A3AD}.Release|x64.Build.0 = Release|Any CPU + {B4B57747-951A-4026-985F-772CE174A3AD}.Release|x86.ActiveCfg = Release|Any CPU + {B4B57747-951A-4026-985F-772CE174A3AD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B4B57747-951A-4026-985F-772CE174A3AD} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection EndGlobal diff --git a/Jellyfin.Database.Providers.Postgres/Properties/AssemblyInfo.cs b/Jellyfin.Database.Providers.Postgres/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..e633071 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +// The test suite verifies internal behaviour (statement rewriting, compatibility bootstrap, logger). +[assembly: InternalsVisibleTo("Jellyfin.Database.Providers.Postgres.Tests")] diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/DatabaseConfigurationTests.cs b/tests/Jellyfin.Database.Providers.Postgres.Tests/DatabaseConfigurationTests.cs new file mode 100644 index 0000000..edbd926 --- /dev/null +++ b/tests/Jellyfin.Database.Providers.Postgres.Tests/DatabaseConfigurationTests.cs @@ -0,0 +1,88 @@ +using System.Xml.Serialization; +using Jellyfin.Database.Implementations.DbConfiguration; +using Npgsql; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// database.xml is the only thing that activates the provider, and Jellyfin parses it with its own +/// serializer. These tests pin the file the plugin writes to the type the server deserialises. +/// +public sealed class DatabaseConfigurationTests +{ + [Fact] + public void WrittenDatabaseXmlRoundTripsThroughJellyfinOptions() + { + var root = Directory.CreateTempSubdirectory("jellyfin-database-xml-"); + try + { + var paths = TestPaths.Create(root.FullName); + + // The password carries XML special characters on purpose: the connection string is written + // inside an XML element, so it has to survive escaping and unescaping. + const string connectionString = "Host=127.0.0.1;Port=5432;Database=Jellyfin;Username=Jellyfin;Password=\"p&wy\""; + PostgresPlugin.WriteDatabaseXml(paths, connectionString, 600); + + DatabaseConfigurationOptions? options; + using (var stream = File.OpenRead(Path.Combine(root.FullName, "database.xml"))) + { + options = (DatabaseConfigurationOptions?)new XmlSerializer(typeof(DatabaseConfigurationOptions)).Deserialize(stream); + } + + Assert.NotNull(options); + Assert.Equal("PLUGIN_PROVIDER", options!.DatabaseType, ignoreCase: true); + + var custom = Assert.IsType(options.CustomProviderOptions); + + // Jellyfin locates the plugin folder by prefix, so PluginName must stay the display name. + Assert.Equal("PostgreSQL Database Provider", custom.PluginName); + Assert.Equal( + Path.GetFileName(typeof(PostgresDatabaseProvider).Assembly.Location), + Path.GetFileName(Path.ChangeExtension(custom.PluginAssembly, "dll")), + ignoreCase: true); + Assert.Contains( + custom.Options, + option => string.Equals(option.Key, "command-timeout", StringComparison.OrdinalIgnoreCase) && option.Value == "600"); + + // WriteDatabaseXml normalises the connection string through NpgsqlConnectionStringBuilder, + // so the values are compared instead of the raw text. + var readBack = PostgresPlugin.ReadActivePgConnectionString(paths); + Assert.NotNull(readBack); + var actual = new NpgsqlConnectionStringBuilder(readBack); + var expected = new NpgsqlConnectionStringBuilder(connectionString); + Assert.Equal(expected.Host, actual.Host); + Assert.Equal(expected.Port, actual.Port); + Assert.Equal(expected.Database, actual.Database); + Assert.Equal(expected.Username, actual.Username); + Assert.Equal(expected.Password, actual.Password); + + // The provider queries with the schema as search path, so raw SQL resolves where the tables are. + Assert.False(string.IsNullOrWhiteSpace(actual.SearchPath)); + } + finally + { + root.Delete(true); + } + } + + [Fact] + public void EngineSwitchIsDetectedFromDatabaseXml() + { + var root = Directory.CreateTempSubdirectory("jellyfin-engine-"); + try + { + var paths = TestPaths.Create(root.FullName); + Assert.False(PostgresPlugin.IsPostgresActive(paths)); + + PostgresPlugin.WriteDatabaseXml(paths, "Host=127.0.0.1;Database=Jellyfin;Username=Jellyfin;Password=x"); + + Assert.True(PostgresPlugin.IsPostgresActive(paths)); + Assert.Equal("Jellyfin", new NpgsqlConnectionStringBuilder(PostgresPlugin.ReadActivePgConnectionString(paths)).Database); + } + finally + { + root.Delete(true); + } + } +} diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj b/tests/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj new file mode 100644 index 0000000..6beedf9 --- /dev/null +++ b/tests/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + enable + enable + false + Jellyfin.Database.Providers.Postgres.Tests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/LoggingTests.cs b/tests/Jellyfin.Database.Providers.Postgres.Tests/LoggingTests.cs new file mode 100644 index 0000000..2864193 --- /dev/null +++ b/tests/Jellyfin.Database.Providers.Postgres.Tests/LoggingTests.cs @@ -0,0 +1,80 @@ +using Jellyfin.Database.Providers.Postgres.Logging; +using Npgsql; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// The plugin log is the only place where a failure is recorded, so its content is part of the contract: +/// a line that says only "something failed" is useless when diagnosing a user report. +/// +public sealed class LoggingTests +{ + [Fact] + public void FailuresCarryLevelMessageAndStackTrace() + { + var path = PostgresLog.CurrentLogPath; + var offset = File.Exists(path) ? new FileInfo(path).Length : 0; + + Exception thrown; + try + { + throw new InvalidOperationException("fallo de prueba del logger"); + } + catch (InvalidOperationException ex) + { + thrown = ex; + } + + PostgresLog.Error("[Test] contexto del fallo", thrown); + + var text = ReadAppended(path, offset); + Assert.Contains("[ERROR]", text, StringComparison.Ordinal); + Assert.Contains("fallo de prueba del logger", text, StringComparison.Ordinal); + Assert.Contains("System.InvalidOperationException", text, StringComparison.Ordinal); + + // The stack trace is what makes the failure diagnosable. + Assert.Contains(nameof(LoggingTests), text, StringComparison.Ordinal); + } + + [Fact] + public void PostgresFailuresCarryTheServerDetail() + { + var path = PostgresLog.CurrentLogPath; + var offset = File.Exists(path) ? new FileInfo(path).Length : 0; + + var exception = new PostgresException( + "duplicate key value violates unique constraint \"PK_UserData\"", + "ERROR", + "ERROR", + "23505", + detail: "Key (ItemId, UserId, CustomDataKey)=(ae19, 1111, A) already exists.", + tableName: "UserData", + constraintName: "PK_UserData"); + + PostgresLog.Error("[Test] violación de PK", exception); + + var text = ReadAppended(path, offset); + Assert.Contains("SqlState=23505", text, StringComparison.Ordinal); + Assert.Contains("Table=UserData", text, StringComparison.Ordinal); + Assert.Contains("Constraint=PK_UserData", text, StringComparison.Ordinal); + Assert.Contains("already exists", text, StringComparison.Ordinal); + } + + [Fact] + public void LogFileLivesNextToTheOtherPluginLogs() + { + Assert.Equal( + Path.GetFileName(TestModule.LogDirectory), + Path.GetFileName(Path.GetDirectoryName(PostgresLog.CurrentLogPath))); + Assert.StartsWith("Postgres-", Path.GetFileName(PostgresLog.CurrentLogPath), StringComparison.Ordinal); + } + + private static string ReadAppended(string path, long offset) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + stream.Seek(offset, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresIntegrationTests.cs b/tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresIntegrationTests.cs new file mode 100644 index 0000000..0f0d948 --- /dev/null +++ b/tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresIntegrationTests.cs @@ -0,0 +1,436 @@ +using System.Globalization; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Postgres.Services; +using Jellyfin.Database.Providers.Postgres.Services.Models; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// Integration tests against a real PostgreSQL server. Each one creates a throw-away database and drops it +/// afterwards; the connection string from POSTGRES_TEST_CONNECTION is only used to issue +/// CREATE/DROP DATABASE, never as the database under test. Without that variable the tests are skipped, so +/// a contributor who only wants to run the unit tests gets a green run. +/// +public sealed class PostgresIntegrationTests +{ + /// + /// Runs the first migration on an empty database, loads it with legacy-looking rows and then applies + /// everything Jellyfin 12.1 adds, including the SQLite-only JSON cleanup and the UUID conversions. + /// + [SkippableFact] + public async Task InitialSchemaAndPopulatedUpgradeWork() + { + RequirePostgres(); + await using var scratch = await Scratch.CreateAsync(); + + using var context = CreateContext(scratch.Database.ConnectionString, new Jellyfin121MigrationInterceptor()); + await context.GetService().MigrateAsync("00000000000000_InitialCreate"); + + // Raw SQL containing braces is executed through Npgsql: ExecuteSqlRaw formats the statement. + await ExecuteAsync( + scratch.Database.ConnectionString, + """ + INSERT INTO "BaseItems" + SELECT (jsonb_populate_record(NULL::"BaseItems", to_jsonb(b) || + '{"Id":"10000000-0000-0000-0000-000000000001","OwnerId":"bad-id","PrimaryVersionId":"20000000000000000000000000000001","ExtraIds":"legacy-extra-ids"}'::jsonb)).* + FROM "BaseItems" b LIMIT 1; + """); + await ExecuteAsync( + scratch.Database.ConnectionString, + """ + INSERT INTO "ItemValues" VALUES + ('40000000-0000-0000-0000-000000000001', 1, 'Duplicate', 'duplicate'), + ('40000000-0000-0000-0000-000000000002', 1, 'Duplicate', 'duplicate'); + INSERT INTO "ItemValuesMap" ("ItemValueId", "ItemId") VALUES + ('40000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001'), + ('40000000-0000-0000-0000-000000000002', '10000000-0000-0000-0000-000000000001'); + INSERT INTO "Permissions" ("Kind", "Value", "RowVersion") VALUES (1, true, 0); + """); + + await context.Database.MigrateAsync(); + + await using (var upgraded = new NpgsqlConnection(scratch.Database.ConnectionString)) + { + await upgraded.OpenAsync(); + Assert.Equal(1, await ScalarAsync(upgraded, "SELECT COUNT(*) FROM \"ItemValues\"")); + Assert.Equal(2, await ScalarAsync(upgraded, "SELECT COUNT(*) FROM \"ItemValuesMap\"")); + Assert.Equal(0, await ScalarAsync(upgraded, "SELECT COUNT(*) FROM \"Permissions\"")); + } + + var converted = await context.BaseItems.SingleAsync(item => item.Id == Guid.Parse("10000000-0000-0000-0000-000000000001")); + Assert.Null(converted.OwnerId); + Assert.Equal(Guid.Parse("20000000-0000-0000-0000-000000000001"), converted.PrimaryVersionId); + Assert.Null(converted.OriginalLanguage); + } + + /// + /// The JSON cleanup Jellyfin 12.1 runs is written for SQLite. This covers the rewritten statement + /// against PostgreSQL, including the synchronous and asynchronous paths and malformed payloads. + /// + [SkippableFact] + public async Task JsonCleanupWorksInSyncAndAsyncPaths() + { + RequirePostgres(); + await using var scratch = await Scratch.CreateAsync(); + + using var context = CreateContext(scratch.Database.ConnectionString, new Jellyfin121MigrationInterceptor()); + await context.Database.MigrateAsync(); + + const string json = "{\"LinkedChildren\":[],\"ExtraIds\":[],\"SupportsExternalTransfer\":true,\"Keep\":42}"; + var id = await context.BaseItems.Select(item => item.Id).FirstAsync(); + await context.Database.ExecuteSqlInterpolatedAsync($"UPDATE \"BaseItems\" SET \"Data\" = {json} WHERE \"Id\" = {id}"); + await ExecuteAsync( + scratch.Database.ConnectionString, + $"UPDATE \"BaseItems\" SET \"Data\" = 'malformed-json' WHERE \"Id\" <> '{id}'"); + + var cleanup = $"""UPDATE "BaseItems" SET "Data" = {Jellyfin121MigrationInterceptor.SqliteCleanup} WHERE json_valid("Data") = 1"""; + Assert.Equal(1, await context.Database.ExecuteSqlRawAsync(cleanup)); + Assert.Equal(0, context.Database.ExecuteSqlRaw(cleanup)); + + var data = await context.BaseItems.AsNoTracking().Where(item => item.Id == id).Select(item => item.Data).SingleAsync(); + Assert.Contains("Keep", data, StringComparison.Ordinal); + Assert.DoesNotContain("LinkedChildren", data, StringComparison.Ordinal); + } + + /// + /// Importing a SQLite database has to work both in public and in a custom schema, and repeating + /// the import must not lose rows that were copied earlier through cascading foreign keys. + /// + [SkippableFact] + public async Task SqliteDatabaseImportsIntoPublicAndCustomSchema() + { + RequirePostgres(); + await using var scratch = await Scratch.CreateAsync(); + + var folder = Directory.CreateTempSubdirectory("jellyfin-import-"); + try + { + var sqlitePath = Path.Combine(folder.FullName, "jellyfin.db"); + await using (var source = CreateSqliteContext(sqlitePath)) + { + await source.Database.EnsureCreatedAsync(); + source.BaseItems.Add(new BaseItemEntity { Id = Guid.Parse("30000000-0000-0000-0000-000000000001"), Type = "Playlist", Name = "Fixture playlist" }); + source.BaseItems.Add(new BaseItemEntity { Id = Guid.Parse("30000000-0000-0000-0000-000000000002"), Type = "Audio", Name = "Fixture song" }); + await source.SaveChangesAsync(); + await source.Database.ExecuteSqlRawAsync( + """ + INSERT INTO "LinkedChildren" ("ParentId", "SortOrder", "ChildId", "ChildType") VALUES + ('30000000-0000-0000-0000-000000000001', 0, '30000000-0000-0000-0000-000000000002', 0), + ('30000000-0000-0000-0000-000000000001', 1, '30000000-0000-0000-0000-000000000002', 0); + CREATE TABLE "__EFMigrationsHistory" ("MigrationId" TEXT PRIMARY KEY, "ProductVersion" TEXT NOT NULL); + INSERT INTO "__EFMigrationsHistory" VALUES ('202609111200000_StripEmbeddedLinkedChildren','12.1.0'); + INSERT INTO "__EFMigrationsHistory" VALUES ('20260815063607_RemoveOrphanedUserPermissionsAndPreferences','10.0.11'); + """); + } + + using var service = new MigrationService(NullLogger.Instance); + foreach (var schema in new[] { "public", "custom_schema" }) + { + var options = new MigrationOptions(sqlitePath, scratch.Database.ConnectionString, schema, 2, true, null); + + await MigrationEngine.RunAsync(options, service, CancellationToken.None); + + // Repeated to verify truncation does not erase rows copied earlier through cascading FKs. + await MigrationEngine.RunAsync(options, service, CancellationToken.None); + + var connectionString = new NpgsqlConnectionStringBuilder(scratch.Database.ConnectionString) { SearchPath = schema }.ConnectionString; + await using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync(); + Assert.Equal(2, await ScalarAsync(pg, "SELECT COUNT(*) FROM \"LinkedChildren\"")); + Assert.Equal(1, await ScalarAsync(pg, "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '202609111200000_StripEmbeddedLinkedChildren'")); + Assert.Equal(0, await ScalarAsync(pg, "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260815063607_RemoveOrphanedUserPermissionsAndPreferences'")); + } + } + finally + { + // SQLite parks connections in a pool; without clearing it the temp file stays locked. + DeleteTemporaryDirectory(folder); + } + } + + /// + /// A SQLite database from an older Jellyfin has to be rejected before anything is written to PostgreSQL, + /// instead of importing a partial schema. + /// + [SkippableFact] + public async Task OldSqliteSchemaIsRejectedBeforeWriting() + { + RequirePostgres(); + await using var scratch = await Scratch.CreateAsync(); + _ = scratch; + + await using var source = new SqliteConnection("Data Source=:memory:"); + await source.OpenAsync(); + + await Assert.ThrowsAsync(() => MigrationCodeMigrations.ValidateSourceAsync(source)); + } + + /// + /// The export is the way back to SQLite, so the produced file has to contain the rows and only the code + /// migration history that actually applies. + /// + [SkippableFact] + public async Task ExportProducesAUsableSqliteDatabase() + { + RequirePostgres(); + await using var scratch = await Scratch.CreateAsync(); + + using var context = CreateContext(scratch.Database.ConnectionString, new Jellyfin121MigrationInterceptor()); + await context.Database.MigrateAsync(); + var parent = await context.BaseItems.Select(item => item.Id).FirstAsync(); + + // The child has to exist, or the playlist membership breaks its foreign key. It is derived from an + // existing row so every NOT NULL column of the table is filled in. + await ExecuteAsync( + scratch.Database.ConnectionString, + """ + INSERT INTO "BaseItems" + SELECT (jsonb_populate_record(NULL::"BaseItems", to_jsonb(b) || + '{"Id":"30000000-0000-0000-0000-000000000002","Type":"Audio","Name":"Fixture song"}'::jsonb)).* + FROM "BaseItems" b LIMIT 1; + """); + await ExecuteAsync( + scratch.Database.ConnectionString, + $""" + INSERT INTO "LinkedChildren" ("ParentId", "SortOrder", "ChildId", "ChildType") VALUES + ('{parent}', 0, '30000000-0000-0000-0000-000000000002', 0), + ('{parent}', 1, '30000000-0000-0000-0000-000000000002', 0); + """); + + var folder = Directory.CreateTempSubdirectory("jellyfin-export-"); + try + { + var exportedPath = Path.Combine(folder.FullName, "export.db"); + await using (var target = CreateSqliteContext(exportedPath)) + { + await target.Database.EnsureCreatedAsync(); + await target.Database.ExecuteSqlRawAsync("CREATE TABLE \"__EFMigrationsHistory\" (\"MigrationId\" TEXT PRIMARY KEY, \"ProductVersion\" TEXT NOT NULL)"); + } + + using (var exporter = new ExportToSqliteService(NullLogger.Instance)) + { + Assert.True(exporter.StartExport(scratch.Database.ConnectionString, exportedPath)); + var deadline = DateTime.UtcNow.AddSeconds(120); + while (exporter.GetProgress().IsRunning && DateTime.UtcNow < deadline) + { + await Task.Delay(100); + } + + var progress = exporter.GetProgress(); + Assert.False(progress.IsRunning, "The export did not finish in time."); + Assert.False(progress.HasError, progress.ErrorMessage); + } + + await using var exported = new SqliteConnection($"Data Source={exportedPath}"); + await exported.OpenAsync(); + await using var query = exported.CreateCommand(); + query.CommandText = "SELECT COUNT(*) FROM \"LinkedChildren\";"; + Assert.Equal(2, Convert.ToInt64(await query.ExecuteScalarAsync(), CultureInfo.InvariantCulture)); + } + finally + { + DeleteTemporaryDirectory(folder); + } + } + + /// + /// Jellyfin relies on min(uuid)/max(uuid) for the home page (latest items per channel). + /// PostgreSQL does not define those aggregates, so the provider has to create them when it prepares the + /// schema. + /// + [SkippableFact] + public async Task CompatibilityAggregatesExistAfterSchemaPreparation() + { + RequirePostgres(); + await using var scratch = await Scratch.CreateAsync(); + + using var context = CreateContext(scratch.Database.ConnectionString, new Jellyfin121MigrationInterceptor()); + await context.Database.MigrateAsync(); + + // The provider creates these objects when EF initialises; Jellyfin always goes through it. + SqliteCompatibilityBootstrap.Ensure(scratch.Database.ConnectionString); + + await using var pg = new NpgsqlConnection(scratch.Database.ConnectionString); + await pg.OpenAsync(); + await using var command = new NpgsqlCommand( + """ + SELECT min(x)::text, max(x)::text + FROM (VALUES ('00000000-0000-0000-0000-000000000002'::uuid), ('00000000-0000-0000-0000-000000000001'::uuid)) t(x); + """, + pg); + await using var reader = await command.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + Assert.Equal("00000000-0000-0000-0000-000000000001", reader.GetString(0)); + Assert.Equal("00000000-0000-0000-0000-000000000002", reader.GetString(1)); + } + + /// + /// The full system backup restores rows through + /// provider.PurgeDatabase(dbContext, tableNames) with schema qualified names taken from EF's + /// model, so every format the server can produce has to work. + /// + [SkippableTheory] + [InlineData("PurgeProbe")] + [InlineData("public.PurgeProbe")] + [InlineData("public.\"PurgeProbe\"")] + [InlineData("\"public\".\"PurgeProbe\"")] + public async Task PurgeDatabaseAcceptsEveryTableNameFormatTheServerPasses(string tableName) + { + RequirePostgres(); + await using var scratch = await Scratch.CreateAsync(); + + using var context = CreateContext(scratch.Database.ConnectionString); + await using var pg = new NpgsqlConnection(scratch.Database.ConnectionString); + await pg.OpenAsync(); + await using (var create = new NpgsqlCommand("""CREATE TABLE IF NOT EXISTS "public"."PurgeProbe" ("Id" uuid PRIMARY KEY);""", pg)) + { + await create.ExecuteNonQueryAsync(); + } + + await using (var insert = new NpgsqlCommand("""INSERT INTO "public"."PurgeProbe" VALUES ('11111111-1111-1111-1111-111111111111') ON CONFLICT DO NOTHING;""", pg)) + { + await insert.ExecuteNonQueryAsync(); + } + + var provider = new PostgresDatabaseProvider(null, null); + await provider.PurgeDatabase(context, [tableName]); + + Assert.Equal(0, await ScalarAsync(pg, """SELECT COUNT(*) FROM "public"."PurgeProbe";""")); + } + + /// + /// The SQL console goes through the same provider path as the UI: read-only statements return rows and + /// mutating ones are rejected before reaching the server. + /// + [SkippableFact] + public async Task SqlConsoleReturnsRowsAndRejectsWrites() + { + RequirePostgres(); + await using var scratch = await Scratch.CreateAsync(); + + using var context = CreateContext(scratch.Database.ConnectionString, new Jellyfin121MigrationInterceptor()); + await context.Database.MigrateAsync(); + + var console = new QueryConsoleService(); + var result = await console.ExecuteAsync( + scratch.Database.ConnectionString, + """SELECT COUNT(*) AS total FROM "BaseItems";""", + "public", + 10, + explain: false, + CancellationToken.None); + + Assert.Equal("total", result.Columns[0]); + Assert.Equal(1, result.RowCount); + + var plan = await console.ExecuteAsync( + scratch.Database.ConnectionString, + """SELECT COUNT(*) FROM "BaseItems";""", + "public", + 10, + explain: true, + CancellationToken.None); + + Assert.True(plan.RowCount > 0, "The execution plan should return rows."); + Assert.Contains("cost=", string.Join(' ', plan.Rows.SelectMany(row => row)), StringComparison.Ordinal); + + await Assert.ThrowsAsync(() => console.ExecuteAsync( + scratch.Database.ConnectionString, + """DELETE FROM "BaseItems";""", + "public", + 10, + explain: false, + CancellationToken.None)); + } + + private static void RequirePostgres() + => Skip.IfNot(TestEnvironment.PostgresAvailable, $"{TestEnvironment.ConnectionVariable} is not set; skipping PostgreSQL integration tests."); + + private static JellyfinDbContext CreateContext(string connectionString, params IInterceptor[] interceptors) + { + var builder = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, pg => pg.MigrationsAssembly(typeof(PostgresDatabaseProvider).Assembly.FullName)); + if (interceptors.Length > 0) + { + builder.AddInterceptors(interceptors); + } + + return new JellyfinDbContext( + builder.Options, + NullLogger.Instance, + new PostgresDatabaseProvider(null, null), + new NoLockBehavior(NullLogger.Instance)); + } + + private static JellyfinDbContext CreateSqliteContext(string path) + => new( + new DbContextOptionsBuilder().UseSqlite($"Data Source={path}").Options, + NullLogger.Instance, + new PostgresDatabaseProvider(null, null), + new NoLockBehavior(NullLogger.Instance)); + + private static async Task ScalarAsync(NpgsqlConnection connection, string sql) + { + await using var command = new NpgsqlCommand(sql, connection); + return Convert.ToInt64(await command.ExecuteScalarAsync(), CultureInfo.InvariantCulture); + } + + /// Runs a fixture statement directly through Npgsql, without EF's statement formatting. + /// Database to run against. + /// Statement to execute. + private static async Task ExecuteAsync(string connectionString, string sql) + { + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(); + } + + /// + /// Deletes a temporary directory holding a SQLite file. The connection pool is cleared first: a pooled + /// connection keeps the file open and Windows then refuses the delete. + /// + /// Directory to remove. + private static void DeleteTemporaryDirectory(DirectoryInfo folder) + { + SqliteConnection.ClearAllPools(); + try + { + folder.Delete(true); + } + catch (IOException) + { + // A leftover temporary directory is not a reason to fail a test. + } + } + + /// Scratch database that drops itself when the test finishes. + private sealed class Scratch : IAsyncDisposable + { + private Scratch(TestEnvironment.ScratchDatabase database) => Database = database; + + internal TestEnvironment.ScratchDatabase Database { get; } + + internal static async Task CreateAsync() => new(await TestEnvironment.CreateScratchDatabaseAsync()); + + public async ValueTask DisposeAsync() + { + // Npgsql parks connections in a pool; without clearing it PostgreSQL would refuse the drop. + NpgsqlConnection.ClearAllPools(); + await using var admin = new NpgsqlConnection(TestEnvironment.PostgresConnection); + await admin.OpenAsync(); + await using var drop = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{Database.Name}\" WITH (FORCE)", admin); + await drop.ExecuteNonQueryAsync(); + } + } +} diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/ProviderContractTests.cs b/tests/Jellyfin.Database.Providers.Postgres.Tests/ProviderContractTests.cs new file mode 100644 index 0000000..c244362 --- /dev/null +++ b/tests/Jellyfin.Database.Providers.Postgres.Tests/ProviderContractTests.cs @@ -0,0 +1,115 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using Jellyfin.Database.Implementations; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Model.Plugins; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// Verifies the parts of the contract with the Jellyfin server that the compiler cannot enforce: how the +/// provider is discovered, which types the server instantiates and that no member was left unimplemented. +/// +public sealed class ProviderContractTests +{ + /// + /// Jellyfin.Server.Implementations loads a plugin provider with + /// assembly.GetExportedTypes().FirstOrDefault(f => f.IsAssignableTo(typeof(IJellyfinDatabaseProvider))), + /// so a second exported implementation would make the selected provider depend on type order. + /// + [Fact] + public void JellyfinDiscoversExactlyOneProvider() + { + var providers = typeof(PostgresDatabaseProvider).Assembly.GetExportedTypes() + .Where(t => typeof(IJellyfinDatabaseProvider).IsAssignableFrom(t)) + .ToArray(); + + Assert.Equal([typeof(PostgresDatabaseProvider)], providers); + } + + /// + /// The service registrator is created with Activator.CreateInstance and the plugin has to be a + /// BasePlugin exposing the dashboard page, or Jellyfin loads nothing at all. + /// + [Fact] + public void EntryPointsMatchWhatJellyfinInstantiates() + { + Assert.NotNull(typeof(PostgresServiceRegistrator).GetConstructor(Type.EmptyTypes)); + Assert.True(typeof(IPluginServiceRegistrator).IsAssignableFrom(typeof(PostgresServiceRegistrator))); + Assert.True(typeof(BasePlugin).IsAssignableFrom(typeof(PostgresPlugin))); + Assert.True(typeof(IHasWebPages).IsAssignableFrom(typeof(PostgresPlugin))); + } + + /// + /// A member of left as throw new NotImplementedException() + /// only fails when the server calls it: PurgeDatabase, for instance, is used when restoring a + /// full system backup. + /// + [Fact] + public void NoInterfaceMemberIsLeftUnimplemented() + { + var stubs = typeof(PostgresDatabaseProvider) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.DeclaringType == typeof(PostgresDatabaseProvider)) + .Where(ThrowsNotImplementedException) + .Select(m => m.Name) + .ToArray(); + + Assert.Empty(stubs); + } + + /// + /// The plugin compiles against the Jellyfin packages it declares; upgrading the server without bumping + /// the declared version is what silently ships a provider built for another release. + /// + [Fact] + public void DeclaredJellyfinVersionMatchesTheReferencedSdk() + { + var projectFile = Path.Combine( + TestEnvironment.RepositoryRoot, + "Jellyfin.Database.Providers.Postgres", + "Jellyfin.Database.Providers.Postgres.csproj"); + var declared = Regex.Match(File.ReadAllText(projectFile), "([^<]+)").Groups[1].Value; + var referenced = typeof(IJellyfinDatabaseProvider).Assembly.GetName().Version ?? new Version(); + + Assert.Equal($"{referenced.Major}.{referenced.Minor}.{referenced.Build}", declared); + } + + /// Detects a method body that constructs a . + /// Method to inspect. + /// when the body throws it. + private static bool ThrowsNotImplementedException(MethodInfo method) + { + var il = method.GetMethodBody()?.GetILAsByteArray(); + if (il is null) + { + return false; + } + + // 0x73 is 'newobj', followed by a 4 byte metadata token. + for (var offset = 0; offset + 4 < il.Length; offset++) + { + if (il[offset] != 0x73) + { + continue; + } + + try + { + if (method.Module.ResolveMethod(BitConverter.ToInt32(il, offset + 1)) is ConstructorInfo ctor + && ctor.DeclaringType == typeof(NotImplementedException)) + { + return true; + } + } + catch (ArgumentException) + { + // Not a method token. + } + } + + return false; + } +} diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs b/tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs new file mode 100644 index 0000000..6cea71a --- /dev/null +++ b/tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs @@ -0,0 +1,79 @@ +using Jellyfin.Database.Providers.Postgres.Services; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// The SQL console is exposed to administrators, so its restrictions are part of the product and are tested +/// as such: only read-only, single statements may reach PostgreSQL. +/// +public sealed class QueryConsoleTests +{ + [Theory] + [InlineData("SELECT 1")] + [InlineData("select \"Id\" from \"BaseItems\" limit 5;")] + [InlineData("SELECT COUNT(*) FROM \"UserData\" -- comentario final")] + [InlineData("/* plan primero */ EXPLAIN SELECT 1")] + [InlineData("WITH valores AS (SELECT 1 AS n) SELECT n FROM valores")] + [InlineData("SHOW search_path")] + [InlineData("TABLE \"BaseItems\"")] + [InlineData("VALUES (1), (2)")] + [InlineData("SELECT 'UPDATE' AS palabra, \"SET\" FROM (VALUES (1)) AS t(\"SET\")")] + public void ReadOnlyStatementsAreAccepted(string sql) + { + var validated = QueryConsoleService.Validate(sql); + + Assert.False(string.IsNullOrWhiteSpace(validated)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("UPDATE \"BaseItems\" SET \"Name\" = 'x'")] + [InlineData("DELETE FROM \"UserData\"")] + [InlineData("INSERT INTO \"UserData\" VALUES (1)")] + [InlineData("DROP TABLE \"BaseItems\"")] + [InlineData("TRUNCATE \"UserData\"")] + [InlineData("ALTER TABLE \"BaseItems\" ADD COLUMN x int")] + [InlineData("CREATE INDEX ix ON \"BaseItems\" (\"Name\")")] + [InlineData("SET search_path = public")] + [InlineData("VACUUM")] + [InlineData("SELECT 1; DROP TABLE \"BaseItems\"")] + [InlineData("SELECT * INTO copia FROM \"BaseItems\"")] + [InlineData("-- despista\nUPDATE \"BaseItems\" SET \"Name\" = 'x'")] + [InlineData("/* despista */ DELETE FROM \"UserData\"")] + public void MutatingOrMultiStatementSqlIsRejected(string sql) + { + var exception = Assert.Throws(() => QueryConsoleService.Validate(sql)); + + Assert.False(string.IsNullOrWhiteSpace(exception.Message)); + } + + /// + /// Comments and literals are ignored while validating, so neither can be used to hide a keyword. + /// + [Fact] + public void KeywordsHiddenInLiteralsAreNotRejected() + { + const string sql = "SELECT 'DELETE FROM \"UserData\"' AS ejemplo"; + + Assert.Equal(sql, QueryConsoleService.Validate(sql)); + } + + /// + /// Validating must not change what is executed: quoted identifiers of the Jellyfin schema are preserved + /// (a previous version returned the masked statement, which broke every quoted table name). + /// + [Fact] + public void TheStatementIsReturnedUnchangedExceptForComments() + { + const string sql = """SELECT COUNT(*) AS total FROM "BaseItems" WHERE "Type" = 'Movie';"""; + + Assert.Equal(sql, QueryConsoleService.Validate(sql)); + + // Comments are removed; everything else, including quoted identifiers, is preserved. + Assert.Equal( + "SELECT COUNT(*) FROM \"BaseItems\"", + QueryConsoleService.Validate("SELECT COUNT(*) FROM \"BaseItems\" -- nota")); + } +} diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs b/tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs new file mode 100644 index 0000000..3d94119 --- /dev/null +++ b/tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs @@ -0,0 +1,87 @@ +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// Audits the raw SQL of the plugin against Jellyfin's own EF model. This is the check that turns a +/// Jellyfin upgrade that renames or drops a table into a failing test instead of a runtime crash. +/// +public sealed partial class SchemaAuditTests +{ + // Objects the plugin uses that are not part of Jellyfin's EF model (EF Core bookkeeping). + private static readonly string[] BookkeepingTables = ["__EFMigrationsHistory", "__EFMigrationsLock"]; + + // Directories of the plugin whose raw SQL does not target the live schema. + private static readonly string[] IgnoredSourcePaths = ["\\bin\\", "\\obj\\", "\\Migrations\\"]; + + [Fact] + public void EveryTableAndQualifiedColumnUsedByRawSqlExistsInTheModel() + { + using var context = TestEnvironment.CreateModelContext(); + var modelTables = context.Model.GetRelationalModel().Tables.ToDictionary( + table => table.Name, + table => table.Columns.Select(column => column.Name).ToHashSet(StringComparer.Ordinal), + StringComparer.Ordinal); + + var unknownTables = new SortedSet(StringComparer.Ordinal); + var unknownColumns = new SortedSet(StringComparer.Ordinal); + var references = 0; + + foreach (var file in Directory.EnumerateFiles(TestEnvironment.PluginSourceDirectory, "*.cs", SearchOption.AllDirectories)) + { + if (IgnoredSourcePaths.Any(path => file.Contains(path, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + foreach (Match match in SqlTableReference().Matches(File.ReadAllText(file))) + { + var table = match.Groups[1].Value; + var column = match.Groups[2].Success ? match.Groups[2].Value : match.Groups[3].Value; + if (BookkeepingTables.Contains(table, StringComparer.Ordinal) + || table.StartsWith("pg_", StringComparison.OrdinalIgnoreCase) + || table.StartsWith("sqlite_", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!modelTables.TryGetValue(table, out var columns)) + { + unknownTables.Add($"{table} ({Path.GetFileName(file)})"); + continue; + } + + references++; + if (!string.IsNullOrEmpty(column) && !columns.Contains(column)) + { + unknownColumns.Add($"{table}.{column} ({Path.GetFileName(file)})"); + } + } + } + + // A pattern that stops matching would turn the two assertions below into a vacuous pass. + Assert.True(references >= 15, $"The auditor should find raw SQL references, it found {references}."); + Assert.Empty(unknownTables); + Assert.Empty(unknownColumns); + } + + /// + /// The EF snapshot has to describe the model Jellyfin 12.1 ships, otherwise the next migration + /// generates SQL for a schema the server does not have. + /// + [Fact] + public void SnapshotMatchesTheJellyfinModel() + { + using var context = TestEnvironment.CreateModelContext(); + Assert.False(context.Database.HasPendingModelChanges()); + } + + // Table references in raw SQL: FROM/JOIN/INTO/ON/UPDATE/COPY/TRUNCATE followed by a quoted identifier, + // plus an optional qualified column ("Table"."Column" or "Table".Column). + [GeneratedRegex( + """(?:\bFROM|\bJOIN|\bINTO|\bON|\bUPDATE|\bCOPY|\bTRUNCATE)\s+"([A-Za-z_][A-Za-z0-9_]*)"\s*(?:\.\s*"?([A-Za-z_][A-Za-z0-9_]*)"?)?""", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex SqlTableReference(); +} diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs b/tests/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs new file mode 100644 index 0000000..fdea4ff --- /dev/null +++ b/tests/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs @@ -0,0 +1,80 @@ +using System.Data.Common; +using Jellyfin.Database.Providers.Postgres.Services; +using Npgsql; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// Covers the statement rewriting the plugin applies to Jellyfin's migration SQL. These are pure unit +/// tests: no database involved, which means a community contributor can inspect exactly what changes. +/// +public sealed class SqlRewriteTests +{ + /// + /// Jellyfin 12.1 cleans up JSON columns with SQLite's json_remove, which PostgreSQL does not + /// have; the plugin translates it to a jsonb expression. + /// + [Fact] + public void SqliteJsonCleanupIsTranslatedToPostgres() + { + var command = Rewrite( + $"""UPDATE "BaseItems" SET "Data" = {Jellyfin121MigrationInterceptor.SqliteCleanup} WHERE json_valid("Data") = 1;"""); + + Assert.DoesNotContain("json_remove", command.CommandText, StringComparison.Ordinal); + Assert.Contains("::jsonb - ARRAY[", command.CommandText, StringComparison.Ordinal); + Assert.Contains("IS JSON OBJECT", command.CommandText, StringComparison.Ordinal); + } + + /// + /// Running the rewrite twice must not change the statement again, otherwise a retried command would + /// end up with a different (nested) statement. + /// + [Theory] + [InlineData("""UPDATE "BaseItems" SET "Data" = json_remove("Data", '$.LinkedChildren', '$.ExtraIds', '$.SupportsExternalTransfer') WHERE json_valid("Data") = 1;""")] + [InlineData("""UPDATE "BaseItems" SET "InheritedParentalRatingValue" = NULL WHERE "OfficialRating" IS NULL OR "OfficialRating" = '';""")] + [InlineData("""UPDATE "BaseItems" SET "InheritedParentalRatingSubValue" = NULL WHERE "OfficialRating" IS NULL OR "OfficialRating" = '';""")] + public void RewritingIsIdempotent(string sql) + { + var once = Rewrite(sql).CommandText; + var twice = Rewrite(once).CommandText; + + Assert.Equal(once, twice); + } + + /// + /// MigrateRatingLevels sets the inherited rating to NULL for every item without a rating. On a + /// large library that rewrites the whole table and aborts the migration on the command timeout, so the + /// statement is restricted to the rows that actually change. + /// + [Theory] + [InlineData("InheritedParentalRatingValue")] + [InlineData("InheritedParentalRatingSubValue")] + public void RatingLevelsUpdateOnlyTouchesRowsThatChange(string column) + { + var command = Rewrite($$"""UPDATE "BaseItems" SET "{{column}}" = NULL WHERE "OfficialRating" IS NULL OR "OfficialRating" = '';"""); + + Assert.Contains($"\"{column}\" IS NOT NULL", command.CommandText, StringComparison.Ordinal); + } + + /// + /// Anything the plugin does not know about has to reach PostgreSQL untouched. + /// + [Theory] + [InlineData("""UPDATE "UserData" SET "Played" = true WHERE "UserId" IS NULL;""")] + [InlineData("""UPDATE "BaseItems" SET "Name" = 'x' WHERE "Id" = '00000000-0000-0000-0000-000000000001';""")] + [InlineData("""DELETE FROM "LinkedChildren" WHERE "ParentId" IS NULL;""")] + public void UnrelatedStatementsAreLeftUntouched(string sql) + { + Assert.Equal(sql, Rewrite(sql).CommandText); + } + + private static DbCommand Rewrite(string sql) + { + var command = new NpgsqlCommand(sql); + + // The interceptor only reads and rewrites CommandText, so no connection or event data is needed. + new Jellyfin121MigrationInterceptor().NonQueryExecuting(command, null!, default); + return command; + } +} diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/TestEnvironment.cs b/tests/Jellyfin.Database.Providers.Postgres.Tests/TestEnvironment.cs new file mode 100644 index 0000000..82cfa7e --- /dev/null +++ b/tests/Jellyfin.Database.Providers.Postgres.Tests/TestEnvironment.cs @@ -0,0 +1,99 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Postgres.Logging; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// Shared context for the suite: repository paths, the scratch database and the plugin logger. +/// +internal static class TestEnvironment +{ + /// Environment variable holding an admin connection string used only to create scratch databases. + internal const string ConnectionVariable = "POSTGRES_TEST_CONNECTION"; + + /// Optional environment variable that enables the pg_dump based integration tests. + internal const string BackupsVariable = "POSTGRES_TEST_BACKUPS"; + + private static string? _repositoryRoot; + + internal static string? PostgresConnection => Environment.GetEnvironmentVariable(ConnectionVariable); + + internal static bool PostgresAvailable => !string.IsNullOrWhiteSpace(PostgresConnection); + + internal static bool BackupTestsEnabled => Environment.GetEnvironmentVariable(BackupsVariable) == "1"; + + internal static string RepositoryRoot => _repositoryRoot ??= FindRepositoryRoot(); + + internal static string PluginSourceDirectory => Path.Combine(RepositoryRoot, "Jellyfin.Database.Providers.Postgres"); + + /// + /// Builds the context Jellyfin itself uses, so the model under test is the server's own one. + /// + /// Connection string of the database to point at. + /// The context. + internal static JellyfinDbContext CreateModelContext(string connectionString = "Host=localhost;Database=unused;Username=unused") + => new( + new DbContextOptionsBuilder() + .UseNpgsql(connectionString, pg => pg.MigrationsAssembly(typeof(PostgresDatabaseProvider).Assembly.FullName)) + .Options, + NullLogger.Instance, + new PostgresDatabaseProvider(null, null), + new NoLockBehavior(NullLogger.Instance)); + + /// + /// Creates an empty database for a single test. The connection string in the environment variable is + /// only used to issue CREATE DATABASE, never as the database under test, so a run cannot touch real data. + /// + /// Name and connection string of the scratch database. + internal static async Task CreateScratchDatabaseAsync() + { + var admin = PostgresConnection + ?? throw new InvalidOperationException($"{ConnectionVariable} is not set."); + var name = string.Concat("jellyfin_provider_test_", Guid.NewGuid().ToString("N")); + + await using (var connection = new NpgsqlConnection(admin)) + { + await connection.OpenAsync(); + await using var create = new NpgsqlCommand(string.Concat("CREATE DATABASE \"", name, "\""), connection); + await create.ExecuteNonQueryAsync(); + } + + return new ScratchDatabase(name, new NpgsqlConnectionStringBuilder(admin) { Database = name }.ConnectionString); + } + + /// Scratch database created exclusively for one test. + /// Database name. + /// Connection string pointing at it. + internal sealed record ScratchDatabase(string Name, string ConnectionString); + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "Jellyfin.Database.Providers.Postgres.sln"))) + { + directory = directory.Parent; + } + + return directory?.FullName + ?? throw new InvalidOperationException($"Repository root not found above '{AppContext.BaseDirectory}'."); + } +} + +/// +/// Redirects the plugin log to a temporary directory for the whole run, so executing the suite never +/// writes to the log directory of an installed server. +/// +internal static class TestModule +{ + internal static string LogDirectory { get; } = Path.Combine(Path.GetTempPath(), "jellyfin-plugin-tests-log"); + + [ModuleInitializer] + internal static void Initialize() => PostgresLog.SetLogDirectory(LogDirectory); +} diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/TestPaths.cs b/tests/Jellyfin.Database.Providers.Postgres.Tests/TestPaths.cs new file mode 100644 index 0000000..f8b9b90 --- /dev/null +++ b/tests/Jellyfin.Database.Providers.Postgres.Tests/TestPaths.cs @@ -0,0 +1,30 @@ +using System.Reflection; +using MediaBrowser.Common.Configuration; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// Minimal implementation: only the paths the plugin writes to are +/// supported, everything else throws so a test never silently depends on an unimplemented path. +/// +public class TestPaths : DispatchProxy +{ + /// Gets or sets the directory used for every path. + public string Root { get; set; } = string.Empty; + + /// Creates a paths instance rooted at . + /// Root directory. + /// The paths instance. + public static IApplicationPaths Create(string root) + { + var paths = Create(); + ((TestPaths)(object)paths).Root = root; + return paths; + } + + /// + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + => targetMethod?.Name is "get_DataPath" or "get_ConfigurationDirectoryPath" + ? Root + : throw new NotSupportedException(targetMethod?.Name); +} diff --git a/tools/Generar-ResumenEjecutivo.ps1 b/tools/Generar-ResumenEjecutivo.ps1 new file mode 100644 index 0000000..e05f157 --- /dev/null +++ b/tools/Generar-ResumenEjecutivo.ps1 @@ -0,0 +1,181 @@ +<# +.SYNOPSIS + Ejecuta la suite de verificación y genera un resumen ejecutivo en Markdown. + +.DESCRIPTION + Corre las pruebas del proyecto y traduce el archivo TRX a un resumen legible en + docs/VERIFICACION-RESUMEN.md: versión del plugin, versión de Jellyfin validada, entorno detectado y el + resultado de cada verificación. + + Cuando no se indica una cadena de conexión, las pruebas que necesitan PostgreSQL se marcan como + omitidas y el resumen lo refleja, de modo que el documento nunca exagera lo que se comprobó. + +.PARAMETER ConnectionString + Cadena de conexión de un usuario administrador de PostgreSQL, usada únicamente para crear y eliminar + bases de datos desechables. Por defecto toma la variable de entorno POSTGRES_TEST_CONNECTION. + +.PARAMETER OutputPath + Ruta del resumen a generar. Por defecto docs/VERIFICACION-RESUMEN.md. + +.EXAMPLE + $env:POSTGRES_TEST_CONNECTION = 'Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=...' + ./tools/Generar-ResumenEjecutivo.ps1 +#> +[CmdletBinding()] +param( + [string]$ConnectionString = $env:POSTGRES_TEST_CONNECTION, + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$testProject = Join-Path $repoRoot 'tests/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj' +$pluginProject = Join-Path $repoRoot 'Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj' +$resultsDir = Join-Path $repoRoot 'tests/TestResults' +$trxName = 'verificacion.trx' +if (-not $OutputPath) { $OutputPath = Join-Path $repoRoot 'docs/VERIFICACION-RESUMEN.md' } + +if ($ConnectionString) { + $env:POSTGRES_TEST_CONNECTION = $ConnectionString + Write-Host 'PostgreSQL configurado: se ejecutarán también las pruebas de integración.' -ForegroundColor Cyan +} +else { + Write-Host 'Sin POSTGRES_TEST_CONNECTION: las pruebas de integración se omitirán.' -ForegroundColor Yellow +} + +Write-Host 'Ejecutando la suite de verificación...' -ForegroundColor Cyan +dotnet test $testProject --configuration Release --logger "trx;LogFileName=$trxName" --results-directory $resultsDir | Out-Host +$testExitCode = $LASTEXITCODE + +$trxPath = Join-Path $resultsDir $trxName +if (-not (Test-Path -LiteralPath $trxPath)) { + throw "No se generó el archivo de resultados: $trxPath" +} + +[xml]$trx = Get-Content -LiteralPath $trxPath +$ns = New-Object System.Xml.XmlNamespaceManager($trx.NameTable) +$ns.AddNamespace('t', 'http://microsoft.com/schemas/VisualStudio/TeamTest/2010') + +$results = @( + $trx.SelectNodes('//t:UnitTestResult', $ns) | ForEach-Object { + # className agrupa las verificaciones por área; algunos runners no lo rellenan, así que se deduce + # del nombre completo del test quitando el parámetro de las teorías. + $className = $_.className + if (-not $className) { $className = ($_.testName -split '\(')[0] -split '\.' | Select-Object -Index 5 } + else { $className = ($className -split '\.')[-1] } + + [pscustomobject]@{ + Name = $_.testName + Class = $className + Outcome = $_.outcome + } + } +) + +$total = $results.Count +$failed = @($results | Where-Object { $_.Outcome -eq 'Failed' }) +$skipped = @($results | Where-Object { $_.Outcome -eq 'NotExecuted' }) +$passed = $total - $failed.Count - $skipped.Count + +# El nombre de la clase agrupa las verificaciones por área funcional. +$areas = $results | Group-Object Class | Sort-Object Name | ForEach-Object { + $bad = @($_.Group | Where-Object { $_.Outcome -eq 'Failed' }).Count + $skippedInArea = @($_.Group | Where-Object { $_.Outcome -eq 'NotExecuted' }).Count + $state = if ($bad -gt 0) { "❌ $bad con error" } + elseif ($skippedInArea -eq $_.Count) { '⚠️ omitida' } + elseif ($skippedInArea -gt 0) { "✅ $($_.Count - $skippedInArea)/$($_.Count) (resto omitidas)" } + else { "✅ $($_.Count)/$($_.Count)" } + + [pscustomobject]@{ Area = $_.Name; Verificaciones = $_.Count; Resultado = $state } +} + +$pluginVersion = (Select-String -LiteralPath (Join-Path $repoRoot 'Directory.Build.props') -Pattern '([^<]+)').Matches.Groups[1].Value +$jellyfinVersion = (Select-String -LiteralPath $pluginProject -Pattern '([^<]+)').Matches.Groups[1].Value +$databaseName = if ($ConnectionString) { + (($ConnectionString -split ';' | Where-Object { $_ -match '^\s*Database\s*=' }) -replace '^\s*Database\s*=\s*', '').Trim() +} +else { + 'no configurada' +} + +$lines = [System.Collections.Generic.List[string]]::new() +$lines.Add('# Resumen ejecutivo de verificación') +$lines.Add('') +$lines.Add('> Documento generado automáticamente por `tools/Generar-ResumenEjecutivo.ps1` a partir del resultado') +$lines.Add('> real de la suite de pruebas. No se edita a mano.') +$lines.Add('') +$lines.Add('| Dato | Valor |') +$lines.Add('| --- | --- |') +$lines.Add("| Fecha de ejecución | $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz') |") +$lines.Add("| Versión del plugin | $pluginVersion |") +$lines.Add("| Jellyfin validado | $jellyfinVersion |") +$lines.Add("| Base de datos de pruebas | $databaseName |") +$lines.Add("| Entorno | $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription) · SDK $((dotnet --version).Trim()) |") +$lines.Add('') +$lines.Add('## Resultado global') +$lines.Add('') +$lines.Add('| Verificaciones | Correctas | Con error | Omitidas |') +$lines.Add('| --- | --- | --- | --- |') +$lines.Add("| $total | $passed | $($failed.Count) | $($skipped.Count) |") +$lines.Add('') +$lines.Add('## Resultado por área') +$lines.Add('') +$lines.Add('| Área | Verificaciones | Resultado |') +$lines.Add('| --- | --- | --- |') +foreach ($area in $areas) { + $lines.Add("| $($area.Area) | $($area.Verificaciones) | $($area.Resultado) |") +} + +$lines.Add('') +$lines.Add('## Detalle') +$lines.Add('') +$lines.Add('| Verificación | Resultado |') +$lines.Add('| --- | --- |') +foreach ($result in ($results | Sort-Object Name)) { + $icon = switch ($result.Outcome) { + 'Passed' { '✅' } + 'NotExecuted' { '⚠️ omitida' } + default { '❌ con error' } + } + $lines.Add("| $($result.Name -replace '^Jellyfin\.Database\.Providers\.Postgres\.Tests\.', '') | $icon |") +} + +if ($skipped.Count -gt 0) { + $lines.Add('') + $lines.Add('## Verificaciones omitidas') + $lines.Add('') + $lines.Add('Estas pruebas necesitan un PostgreSQL accesible y se omitieron porque la ejecución no recibió') + $lines.Add('`POSTGRES_TEST_CONNECTION`. El resultado global **no** las cuenta como correctas.') + $lines.Add('') + foreach ($result in $skipped) { + $lines.Add("- $($result.Name -replace '^Jellyfin\.Database\.Providers\.Postgres\.Tests\.', '')") + } +} + +$lines.Add('') +$lines.Add('## Cómo reproducirlo') +$lines.Add('') +$lines.Add('```bash') +$lines.Add('# Sólo verificaciones sin base de datos') +$lines.Add('dotnet test tests/Jellyfin.Database.Providers.Postgres.Tests') +$lines.Add('') +$lines.Add('# Verificaciones completas, incluida la integración con PostgreSQL') +$lines.Add('$env:POSTGRES_TEST_CONNECTION = "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=***"') +$lines.Add('./tools/Generar-ResumenEjecutivo.ps1') +$lines.Add('```') +$lines.Add('') +$lines.Add('Las pruebas de integración crean su propia base de datos desechable y la eliminan al terminar:') +$lines.Add('la cadena de conexión indicada se usa únicamente para `CREATE DATABASE` / `DROP DATABASE`.') +$lines.Add('') + +$outputDirectory = Split-Path -Parent $OutputPath +if (-not (Test-Path -LiteralPath $outputDirectory)) { + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +} +$lines -join [Environment]::NewLine | Set-Content -LiteralPath $OutputPath -Encoding UTF8 + +Write-Host "Resumen generado: $OutputPath" -ForegroundColor Green +Write-Host "Resultado: $passed correctas, $($failed.Count) con error, $($skipped.Count) omitidas de $total." -ForegroundColor Cyan + +exit $testExitCode From f960fd9930cdf8e0d8a18f3fcef6c0c1d18d3af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 02:07:07 -0700 Subject: [PATCH 02/15] feat(sql): consola de consultas de solo lectura en el panel Endpoint POST Plugins/PostgresProvider/Query (solo administradores) con validacion en servidor: una sentencia por peticion, whitelist SELECT/WITH/TABLE/VALUES/EXPLAIN/SHOW, palabras de escritura bloqueadas, maximo de filas y timeout corto, con auditoria en el log del plugin. La pestana SQL del panel trae plantillas de diagnostico, historial, EXPLAIN e i18n ES/EN. --- .../Controllers/Models/QueryRequest.cs | 20 ++ .../Controllers/PostgresController.cs | 141 +++++++- .../PostgresServiceRegistrator.cs | 1 + .../Services/QueryConsoleService.cs | 329 ++++++++++++++++++ .../Web/configurationPage.html | 260 +++++++++++++- 5 files changed, 730 insertions(+), 21 deletions(-) create mode 100644 Jellyfin.Database.Providers.Postgres/Controllers/Models/QueryRequest.cs create mode 100644 Jellyfin.Database.Providers.Postgres/Services/QueryConsoleService.cs diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/Models/QueryRequest.cs b/Jellyfin.Database.Providers.Postgres/Controllers/Models/QueryRequest.cs new file mode 100644 index 0000000..31b19c8 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Controllers/Models/QueryRequest.cs @@ -0,0 +1,20 @@ +namespace Jellyfin.Database.Providers.Postgres.Controllers.Models; + +/// +/// Request of the ad-hoc SQL console. Only read-only statements are accepted; the service that validates +/// and executes it is QueryConsoleService. +/// +public class QueryRequest +{ + /// Gets or sets the statement to execute. + public string Sql { get; set; } = string.Empty; + + /// Gets or sets the maximum number of rows to return. + public int MaxRows { get; set; } = 200; + + /// + /// Gets or sets a value indicating whether the execution plan should be returned instead of the rows. + /// The plan is generated without ANALYZE, so the statement is only planned, never run for real. + /// + public bool Explain { get; set; } +} diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs b/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs index dc9def1..c568862 100644 --- a/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs +++ b/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs @@ -330,8 +330,18 @@ public ActionResult Activate([FromBody] ActivateRequest request) // Schedule restart after response is sent _ = Task.Run(async () => { - await Task.Delay(500).ConfigureAwait(false); - _systemManager.Restart(); + try + { + await Task.Delay(500).ConfigureAwait(false); + _systemManager.Restart(); + } + catch (Exception ex) + { + // A restart that fails here would leave the user with a plugin that is configured + // for PostgreSQL but a server still running SQLite, so it has to be logged. + _logger.LogError(ex, "Failed to restart Jellyfin after activating PostgreSQL."); + Logging.PostgresLog.Error("[ENGINE SWITCH] No se pudo reiniciar Jellyfin tras activar PostgreSQL", ex); + } }); return Accepted(new @@ -361,10 +371,10 @@ public ActionResult Deactivate() ? $"{new System.IO.FileInfo(sqlitePath).Length / 1_048_576.0:F1} MB" : "(file not found)"; - Logging.PostgresLog.Warn("[ENGINE SWITCH] PostgreSQL → SQLite"); - Logging.PostgresLog.Warn($"[ENGINE SWITCH] database.xml eliminado — Jellyfin usará SQLite al reiniciar."); - Logging.PostgresLog.Warn($"[ENGINE SWITCH] SQLite destino: {sqlitePath} ({sqliteSize})"); - Logging.PostgresLog.Warn("[ENGINE SWITCH] Reiniciando Jellyfin..."); + Logging.PostgresLog.Info("[ENGINE SWITCH] PostgreSQL → SQLite"); + Logging.PostgresLog.Info($"[ENGINE SWITCH] database.xml eliminado — Jellyfin usará SQLite al reiniciar."); + Logging.PostgresLog.Info($"[ENGINE SWITCH] SQLite destino: {sqlitePath} ({sqliteSize})"); + Logging.PostgresLog.Info("[ENGINE SWITCH] Reiniciando Jellyfin..."); } else { @@ -373,8 +383,16 @@ public ActionResult Deactivate() _ = Task.Run(async () => { - await Task.Delay(500).ConfigureAwait(false); - _systemManager.Restart(); + try + { + await Task.Delay(500).ConfigureAwait(false); + _systemManager.Restart(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to restart Jellyfin after reverting to SQLite."); + Logging.PostgresLog.Error("[ENGINE SWITCH] No se pudo reiniciar Jellyfin tras volver a SQLite", ex); + } }); return Accepted(new { Status = "Reverted to SQLite. Jellyfin is restarting..." }); @@ -405,7 +423,7 @@ public async Task> TruncateAllTables(CancellationToken canc { var result = await MaintenanceService.TruncateAllTablesAsync(connStr, cancellationToken) .ConfigureAwait(false); - Logging.PostgresLog.Warn($"[Maintenance] TruncateAllTables: {result.TablesAffected} tablas truncadas."); + Logging.PostgresLog.Info($"[Maintenance] TruncateAllTables: {result.TablesAffected} tablas truncadas."); return Ok(result); } catch (Exception ex) @@ -439,7 +457,7 @@ public async Task> GetStats( var connCount = await MaintenanceService.GetConnectionCountAsync(connStr, cancellationToken) .ConfigureAwait(false); - Logging.PostgresLog.Warn( + Logging.PostgresLog.Info( $"[Stats] BD: {dbSize} | Conexiones activas: {connCount} | Tablas: {tables.Count} | Schema: {effectiveSchema}"); return Ok(new @@ -594,7 +612,7 @@ public async Task> Backup([FromBody] BackupRequest? request var compress = request?.Compress ?? config?.BackupCompression ?? true; var pgBinPath = string.IsNullOrWhiteSpace(request?.PgBinPath) ? config?.PgBinPath - : request?.PgBinPath; + : request.PgBinPath; try { @@ -743,7 +761,7 @@ public ActionResult StartExportToSqlite([FromBody] StartExportToSqliteRe var rawPath = string.IsNullOrWhiteSpace(request?.TargetSqlitePath) ? ExportToSqliteService.DetectDefaultSqlitePath(_appPaths.DataPath) - : request?.TargetSqlitePath ?? ExportToSqliteService.DetectDefaultSqlitePath(_appPaths.DataPath); + : request.TargetSqlitePath; // Resolve final .db file path: if the user gave a directory, append jellyfin.db. var sqlitePath = ResolveExportFilePath(rawPath); @@ -761,12 +779,18 @@ public ActionResult StartExportToSqlite([FromBody] StartExportToSqliteRe } ValidatedFileMove(sqlitePath, bkpPath); - _logger.LogInformation("Existing SQLite file renamed to {Bkp}", bkpPath); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Existing SQLite file renamed to {Bkp}", bkpPath); + } } else if (mode == "overwrite") { ValidatedFileDelete(sqlitePath); - _logger.LogInformation("Existing SQLite file deleted for overwrite: {Path}", sqlitePath); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Existing SQLite file deleted for overwrite: {Path}", sqlitePath); + } } // mode == "use": keep as-is @@ -943,14 +967,86 @@ public ActionResult ApplyOptimizations() _ = Task.Run(async () => { - await PostgresDatabaseProvider.RunOptimizationsAsync( - connStr, enableSearch, enableVacuum, _logger, CancellationToken.None) - .ConfigureAwait(false); + try + { + await PostgresDatabaseProvider.RunOptimizationsAsync( + connStr, enableSearch, enableVacuum, _logger, CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception ex) + { + // The job runs unattended after the 202 response, so nothing else can surface it. + _logger.LogError(ex, "Background database optimization job failed."); + Logging.PostgresLog.Error("[Optimization] El trabajo en segundo plano fall\u00f3", ex); + } }); return Accepted(new { Status = "Optimization job started in background." }); } + // ───────────────────────────────────────────────────────────────────────── + // Read-only SQL console + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Runs a read-only statement sent from the in-dashboard SQL console. Only SELECT-like statements are + /// accepted, one per request, with a bounded number of rows and a short command timeout. + /// + /// Statement, row limit and whether to return the execution plan. + /// Service that validates and executes the statement. + /// Cancellation token. + /// The result set, or the reason why the statement was rejected. + [HttpPost("Query")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> RunQuery( + [FromBody] QueryRequest request, + [FromServices] QueryConsoleService queryConsole, + CancellationToken cancellationToken) + { + var connectionString = GetActiveConnectionString(); + if (connectionString is null) + { + return NoActiveConnection(); + } + + var schema = PostgresPlugin.Instance?.Configuration.Schema ?? DefaultSchema; + + try + { + var result = await queryConsole + .ExecuteAsync(connectionString, request.Sql, schema, request.MaxRows, request.Explain, cancellationToken) + .ConfigureAwait(false); + + return Ok(new + { + Success = true, + result.Columns, + result.Rows, + result.RowCount, + result.Truncated, + result.DurationMs, + MaxRows = QueryConsoleService.MaxRowsLimit, + TimeoutSeconds = QueryConsoleService.DefaultTimeoutSeconds + }); + } + catch (ArgumentException ex) + { + Logging.PostgresLog.Warn($"[QueryConsole] Consulta rechazada: {ex.Message}"); + return BadRequest(new { Success = false, Error = ex.Message }); + } + catch (Npgsql.PostgresException ex) + { + Logging.PostgresLog.Error($"[QueryConsole] PostgreSQL rechazó la consulta ({ex.SqlState})", ex); + return BadRequest(new { Success = false, Error = $"({ex.SqlState}) {ex.MessageText}" }); + } + catch (Exception ex) + { + Logging.PostgresLog.Error("[QueryConsole] Error ejecutando la consulta", ex); + return BadRequest(new { Success = false, Error = ex.Message }); + } + } + // ─────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── @@ -1036,7 +1132,11 @@ private bool TrySeedSqliteSchema(string sqlitePath, string dataPath, ILogger log try { CreateSqliteSchemaViaEfCore(sqlitePath); - logger.LogInformation("SQLite schema created via Jellyfin SQLite provider at {Path}", sqlitePath); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("SQLite schema created via Jellyfin SQLite provider at {Path}", sqlitePath); + } + return true; } catch (Exception ex) @@ -1073,7 +1173,10 @@ private void CreateSqliteSchemaViaEfCore(string sqlitePath) "Jellyfin.Database.Providers.Sqlite.dll not found. " + $"Searched: {string.Join(", ", candidates)}"); - _logger.LogInformation("Loading SQLite provider from: {Path}", sqliteDllPath); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Loading SQLite provider from: {Path}", sqliteDllPath); + } // Load into the default ALC — all EF Core dependencies are already present in the server. var sqliteAsm = System.Runtime.Loader.AssemblyLoadContext.Default diff --git a/Jellyfin.Database.Providers.Postgres/PostgresServiceRegistrator.cs b/Jellyfin.Database.Providers.Postgres/PostgresServiceRegistrator.cs index 34a329e..b5e5a35 100644 --- a/Jellyfin.Database.Providers.Postgres/PostgresServiceRegistrator.cs +++ b/Jellyfin.Database.Providers.Postgres/PostgresServiceRegistrator.cs @@ -26,6 +26,7 @@ public void RegisterServices(IServiceCollection serviceCollection, IServerApplic serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Jellyfin.Database.Providers.Postgres/Services/QueryConsoleService.cs b/Jellyfin.Database.Providers.Postgres/Services/QueryConsoleService.cs new file mode 100644 index 0000000..e4974e4 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/QueryConsoleService.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Providers.Postgres.Logging; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Runs ad-hoc read-only statements against the configured database, for the cases where an administrator +/// has to inspect or operate on PostgreSQL directly. +/// +/// +/// The console is deliberately restricted: only statements that cannot modify data or structure are +/// accepted, a single statement per request, a bounded number of rows and a short command timeout. +/// Everything that is executed is written to the plugin log, so a change made from the console can always +/// be traced back. +/// +public sealed class QueryConsoleService +{ + /// Hard ceiling for the requested row limit. + internal const int MaxRowsLimit = 5000; + + /// Statements are expected to be fast; a long one is almost always a mistake. + internal const int DefaultTimeoutSeconds = 30; + + // Only read-only entry points are allowed. + private static readonly string[] AllowedStarters = ["SELECT", "WITH", "TABLE", "VALUES", "EXPLAIN", "SHOW"]; + + // Statements that would modify data, structure or server state. Checked as whole words. + private static readonly string[] ForbiddenKeywords = + [ + "INSERT", "UPDATE", "DELETE", "MERGE", "TRUNCATE", "DROP", "ALTER", "CREATE", "GRANT", "REVOKE", + "COPY", "VACUUM", "ANALYZE", "REINDEX", "CLUSTER", "REFRESH", "CALL", "DO", "SET", "RESET", "LOCK", + "COMMIT", "ROLLBACK", "BEGIN", "SAVEPOINT", "DISCARD", "LISTEN", "NOTIFY", "IMPORT", "REASSIGN", + "CHECKPOINT", "ATTACH", "DETACH", "PREPARE", "EXECUTE", "DEALLOCATE", "INTO", "SECURITY", "REPLACE", + ]; + + /// + /// Validates a statement and returns it ready to be executed. + /// + /// Statement typed by the administrator. + /// The normalised statement. + /// The statement is empty, not read-only or contains several statements. + internal static string Validate(string sql) + { + if (string.IsNullOrWhiteSpace(sql)) + { + throw new ArgumentException("La consulta está vacía.", nameof(sql)); + } + + // Two views of the statement: the one that will be executed (comments removed, literals and quoted + // identifiers untouched) and the one used to check keywords (literals and identifiers emptied, so a + // keyword inside a literal is not mistaken for a statement). + var (cleaned, masked) = SplitCommentsAndLiterals(sql); + var statement = cleaned.Trim(); + if (statement.Length == 0) + { + throw new ArgumentException("La consulta no contiene ninguna sentencia.", nameof(sql)); + } + + var analysis = masked.Trim(); + + // One statement per request: a trailing semicolon is fine, an inner one is not. + var withoutTrailingSemicolon = analysis.TrimEnd(';'); + if (withoutTrailingSemicolon.Contains(';', StringComparison.Ordinal)) + { + throw new ArgumentException("Solo se permite una sentencia por consulta.", nameof(sql)); + } + + var candidate = withoutTrailingSemicolon.TrimStart('(').TrimStart(); + var starter = FirstWord(candidate); + if (!AllowedStarters.Contains(starter, StringComparer.Ordinal)) + { + throw new ArgumentException( + string.Create( + CultureInfo.InvariantCulture, + $"Solo se permiten consultas de lectura ({string.Join(", ", AllowedStarters)}). Se recibió '{starter}'."), + nameof(sql)); + } + + foreach (var keyword in ForbiddenKeywords) + { + if (ContainsKeyword(candidate, keyword)) + { + throw new ArgumentException( + string.Create(CultureInfo.InvariantCulture, $"La consulta contiene '{keyword}', que está bloqueado en la consola."), + nameof(sql)); + } + } + + return statement; + } + + /// + /// Executes a previously validated statement and returns at most rows. + /// + /// Connection string of the active database. + /// Statement to execute. + /// Schema to expose as search path, so tables can be queried by name. + /// Maximum number of rows to return. + /// Whether to return the execution plan instead of the rows. + /// Cancellation token. + /// The result set. + /// The statement was rejected by . + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "Statement comes from the administrator using the in-dashboard console; it is restricted to read-only single statements by Validate before reaching this call, and the console requires the elevation policy.")] + internal async Task ExecuteAsync( + string connectionString, + string sql, + string schema, + int maxRows, + bool explain, + CancellationToken cancellationToken) + { + var statement = Validate(sql); + var limit = Math.Clamp(maxRows, 1, MaxRowsLimit); + + if (explain) + { + // Without ANALYZE the statement is planned, never executed. + statement = string.Concat("EXPLAIN (VERBOSE, COSTS) ", statement); + } + + var builder = new NpgsqlConnectionStringBuilder(connectionString) + { + // Short timeout: a console query that takes longer than this is a mistake, not a job. + CommandTimeout = DefaultTimeoutSeconds, + SearchPath = string.Concat("\"", schema.Replace("\"", "\"\"", StringComparison.Ordinal), "\""), + }; + + var stopwatch = Stopwatch.StartNew(); + var columns = new List(); + var rows = new List(); + var truncated = false; + + // Synchronous disposal, as elsewhere in the plugin: the pooled Npgsql objects are cheap to dispose + // and this keeps CA2007 (ConfigureAwait on every awaited operation) satisfied. + using var connection = new NpgsqlConnection(builder.ConnectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + using var command = new NpgsqlCommand(statement, connection); + using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + + for (var field = 0; field < reader.FieldCount; field++) + { + columns.Add(reader.GetName(field)); + } + + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (rows.Count >= limit) + { + truncated = true; + break; + } + + var row = new string?[reader.FieldCount]; + for (var field = 0; field < reader.FieldCount; field++) + { + row[field] = await reader.IsDBNullAsync(field, cancellationToken).ConfigureAwait(false) + ? null + : FormatValue(reader.GetValue(field)); + } + + rows.Add(row); + } + + stopwatch.Stop(); + var durationMs = stopwatch.ElapsedMilliseconds; + + // Audit trail: whoever looks at the plugin log can see what was executed from the console. + PostgresLog.Info( + string.Create( + CultureInfo.InvariantCulture, + $"[QueryConsole] {(explain ? "PLAN" : "SELECT")} {rows.Count} fila(s) en {durationMs} ms{(truncated ? " (truncado)" : string.Empty)}: {Preview(statement)}")); + + return new QueryResult(columns, rows, rows.Count, truncated, durationMs); + } + + /// + /// Splits a statement in two: the cleaned text that is actually executed (comments removed) and a masked + /// copy where literals and quoted identifiers are emptied, used to look for forbidden keywords without + /// matching the contents of a string. + /// + /// Statement to clean. + /// The text to execute and the text to analyse. + private static (string Cleaned, string Masked) SplitCommentsAndLiterals(string sql) + { + var cleaned = new StringBuilder(sql.Length); + var masked = new StringBuilder(sql.Length); + var index = 0; + + while (index < sql.Length) + { + var current = sql[index]; + + if (current == '-' && index + 1 < sql.Length && sql[index + 1] == '-') + { + while (index < sql.Length && sql[index] != '\n') + { + index++; + } + + cleaned.Append('\n'); + masked.Append('\n'); + continue; + } + + if (current == '/' && index + 1 < sql.Length && sql[index + 1] == '*') + { + index += 2; + while (index + 1 < sql.Length && !(sql[index] == '*' && sql[index + 1] == '/')) + { + index++; + } + + index = Math.Min(index + 2, sql.Length); + cleaned.Append(' '); + masked.Append(' '); + continue; + } + + if (current is '\'' or '"') + { + var start = index; + var quote = current; + index++; + while (index < sql.Length) + { + if (sql[index] == quote) + { + // A doubled quote is an escaped quote inside the literal. + if (index + 1 < sql.Length && sql[index + 1] == quote) + { + index += 2; + continue; + } + + index++; + break; + } + + index++; + } + + cleaned.Append(sql, start, index - start); + masked.Append(quote).Append(quote); + continue; + } + + cleaned.Append(current); + masked.Append(current); + index++; + } + + return (cleaned.ToString(), masked.ToString()); + } + + private static string FirstWord(string sql) + { + var end = 0; + while (end < sql.Length && (char.IsLetter(sql[end]) || sql[end] == '_')) + { + end++; + } + + return sql[..end].ToUpperInvariant(); + } + + private static string FormatValue(object value) + => value switch + { + byte[] bytes => string.Create(CultureInfo.InvariantCulture, $"byte[{bytes.Length}]"), + DateTime dateTime => dateTime.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture), + DateTimeOffset offset => offset.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture), + Array array => string.Create(CultureInfo.InvariantCulture, $"[{array.Length} elementos]"), + _ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty, + }; + + private static string Preview(string sql) + => sql.Length <= 400 ? sql : string.Concat(sql.AsSpan(0, 400), "…"); + + /// + /// Looks for a whole word in a statement. Written by hand instead of with a regular expression so the + /// check does not depend on culture or on compiling a pattern per keyword. + /// + /// Statement to inspect. + /// Word to look for. + /// when the word appears on its own. + private static bool ContainsKeyword(string sql, string keyword) + { + var index = 0; + while (index < sql.Length) + { + if (!IsWordCharacter(sql[index])) + { + index++; + continue; + } + + var start = index; + while (index < sql.Length && IsWordCharacter(sql[index])) + { + index++; + } + + if (string.Equals(sql[start..index], keyword, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static bool IsWordCharacter(char value) => char.IsLetterOrDigit(value) || value == '_'; + + /// Result of an ad-hoc statement. + /// Column names of the result set. + /// Rows, as text values (null for SQL NULL). + /// Number of returned rows. + /// Whether more rows were available than the requested limit. + /// Server round trip time in milliseconds. + internal sealed record QueryResult(IReadOnlyList Columns, IReadOnlyList Rows, int RowCount, bool Truncated, long DurationMs); +} diff --git a/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html b/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html index 9d130e7..1b9e8e2 100644 --- a/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html +++ b/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html @@ -196,6 +196,7 @@

PostgreSQL Databa + @@ -518,6 +519,49 @@

Salud de la base de datos

+ + + @@ -678,7 +722,35 @@

Salud de la base de datos

msg_health_repairable_tag: '[auto-repairable]', health_sev_ok: 'All checks passed', health_sev_warn: 'Warnings detected', - health_sev_error: 'Critical issues detected' + health_sev_error: 'Critical issues detected', + tab_query: 'SQL console', + heading_query: 'Read-only SQL console', + desc_query: 'Run read-only statements against PostgreSQL: SELECT, WITH, TABLE, VALUES, EXPLAIN or SHOW. One statement per query, a bounded row count and a 30 second timeout. Everything executed here is written to the plugin log.', + field_query_template: 'Template', + preset_query_empty: '\u2014 Choose a template \u2014', + preset_query_tables: 'Largest tables', + preset_query_slow: 'Slowest queries (pg_stat_statements)', + preset_query_activity: 'Current activity', + preset_query_connections: 'Connections by state', + preset_query_unused_indexes: 'Unused indexes', + preset_query_dead_tuples: 'Dead tuples and pending maintenance', + preset_query_locks: 'Waiting locks', + preset_query_extensions: 'Installed extensions', + preset_query_server: 'Server and database info', + field_query_max_rows: 'Max rows', + label_query_explain: 'Plan only (EXPLAIN)', + button_run_query: 'Run statement', + button_clear_query: 'Clear', + heading_query_history: 'History', + hint_query_history: 'Click an entry to load it again.', + msg_query_running: 'Running statement...', + msg_query_empty: 'Write a statement first.', + msg_query_error: 'Error: ', + msg_query_rows_prefix: 'Rows: ', + msg_query_ms_suffix: ' ms', + msg_query_truncated_suffix: ' (truncated to the row limit)', + msg_query_null: 'NULL', + msg_query_plan_note: 'Execution plan: the statement was planned, not executed.' }, es: { title_main: 'PostgreSQL Database Provider', @@ -829,7 +901,35 @@

Salud de la base de datos

msg_health_repairable_tag: '[auto-reparable]', health_sev_ok: 'Todos los checks pasaron', health_sev_warn: 'Advertencias detectadas', - health_sev_error: 'Problemas criticos detectados' + health_sev_error: 'Problemas criticos detectados', + tab_query: 'Consola SQL', + heading_query: 'Consola SQL de solo lectura', + desc_query: 'Ejecuta sentencias de solo lectura sobre PostgreSQL: SELECT, WITH, TABLE, VALUES, EXPLAIN o SHOW. Una sentencia por consulta, con limite de filas y 30 s de timeout. Todo lo que se ejecuta queda registrado en el log del plugin.', + field_query_template: 'Plantilla', + preset_query_empty: '\u2014 Elegir una plantilla \u2014', + preset_query_tables: 'Tablas mas grandes', + preset_query_slow: 'Consultas mas lentas (pg_stat_statements)', + preset_query_activity: 'Actividad actual', + preset_query_connections: 'Conexiones por estado', + preset_query_unused_indexes: 'Indices sin uso', + preset_query_dead_tuples: 'Tuplas muertas y mantenimiento pendiente', + preset_query_locks: 'Bloqueos en espera', + preset_query_extensions: 'Extensiones instaladas', + preset_query_server: 'Informacion del servidor y la base', + field_query_max_rows: 'Filas maximas', + label_query_explain: 'Solo plan (EXPLAIN)', + button_run_query: 'Ejecutar sentencia', + button_clear_query: 'Limpiar', + heading_query_history: 'Historial', + hint_query_history: 'Haz clic en una entrada para volver a cargarla.', + msg_query_running: 'Ejecutando sentencia...', + msg_query_empty: 'Escribe primero una sentencia.', + msg_query_error: 'Error: ', + msg_query_rows_prefix: 'Filas: ', + msg_query_ms_suffix: ' ms', + msg_query_truncated_suffix: ' (truncado al limite de filas)', + msg_query_null: 'NULL', + msg_query_plan_note: 'Plan de ejecucion: la sentencia se planifico, no se ejecuto.' } }; @@ -979,6 +1079,20 @@

Salud de la base de datos

if (tabs.length >= 4) { tabs[3].innerHTML = '💊 ' + t('tab_health', 'Health'); } + if (tabs.length >= 5) { + tabs[4].innerHTML = '📋 ' + t('tab_query', 'SQL console'); + } + + setText('queryHeading', t('heading_query', 'Read-only SQL console')); + setHtml('queryDescription', t('desc_query', 'Run read-only statements against PostgreSQL.')); + setInputLabel('queryTemplate', t('field_query_template', 'Template')); + setInputLabel('queryMaxRows', t('field_query_max_rows', 'Max rows')); + setText('queryExplainLabel', t('label_query_explain', 'Plan only (EXPLAIN)')); + setText('btnRunQueryLabel', t('button_run_query', 'Run statement')); + setText('btnClearQueryLabel', t('button_clear_query', 'Clear')); + setText('queryHistoryHeading', t('heading_query_history', 'History')); + setText('queryHistoryHint', t('hint_query_history', 'Click an entry to load it again.')); + buildQueryPresets(); setText('exportHeading', t('heading_export', 'Export PostgreSQL \u2192 SQLite')); setHtml('exportDescription', t('desc_export', 'Writes all PostgreSQL data directly into a SQLite .db file. Useful to recover the original database or revert to SQLite.')); @@ -1143,6 +1257,148 @@

Salud de la base de datos

_dlg.showModal(); } + // ── Read-only SQL console ─────────────────────────────────────── + // Statements are validated again on the server (QueryConsoleService): this side only helps. + var QUERY_PRESETS = [ + { key: 'preset_query_empty', sql: '' }, + { key: 'preset_query_tables', sql: 'SELECT c.relname AS tabla, pg_size_pretty(pg_total_relation_size(c.oid)) AS total, s.n_live_tup AS filas\nFROM pg_class c\nJOIN pg_stat_user_tables s ON s.relid = c.oid\nWHERE c.relkind = \'r\'\nORDER BY pg_total_relation_size(c.oid) DESC\nLIMIT 25;' }, + { key: 'preset_query_slow', sql: 'SELECT calls AS llamadas, ROUND(mean_exec_time::numeric, 1) AS ms_media, ROUND(total_exec_time::numeric) AS ms_total, rows AS filas, LEFT(query, 120) AS consulta\nFROM pg_stat_statements\nORDER BY mean_exec_time DESC\nLIMIT 20;' }, + { key: 'preset_query_activity', sql: 'SELECT pid, state AS estado, wait_event_type AS espera, LEFT(query, 100) AS consulta\nFROM pg_stat_activity\nWHERE datname = current_database() AND pid <> pg_backend_pid()\nORDER BY query_start;' }, + { key: 'preset_query_connections', sql: 'SELECT COALESCE(state, \'(sin estado)\') AS estado, COUNT(*) AS conexiones\nFROM pg_stat_activity\nWHERE datname = current_database()\nGROUP BY 1\nORDER BY 2 DESC;' }, + { key: 'preset_query_unused_indexes', sql: 'SELECT relname AS tabla, indexrelname AS indice, idx_scan AS usos, pg_size_pretty(pg_relation_size(indexrelid)) AS tamano\nFROM pg_stat_user_indexes\nWHERE idx_scan = 0\nORDER BY pg_relation_size(indexrelid) DESC\nLIMIT 20;' }, + { key: 'preset_query_dead_tuples', sql: 'SELECT relname AS tabla, n_live_tup AS filas_vivas, n_dead_tup AS tuplas_muertas, last_autovacuum, last_autoanalyze\nFROM pg_stat_user_tables\nORDER BY n_dead_tup DESC\nLIMIT 15;' }, + { key: 'preset_query_locks', sql: 'SELECT l.pid, l.locktype, l.mode, l.granted AS concedido, LEFT(a.query, 80) AS consulta\nFROM pg_locks l\nJOIN pg_stat_activity a ON a.pid = l.pid\nWHERE NOT l.granted;' }, + { key: 'preset_query_extensions', sql: 'SELECT extname AS extension, extversion AS version FROM pg_extension ORDER BY extname;' }, + { key: 'preset_query_server', sql: 'SELECT current_database() AS base, current_schema() AS esquema, current_setting(\'search_path\') AS search_path, pg_size_pretty(pg_database_size(current_database())) AS tamano, version() AS version;' } + ]; + + var queryHistoryEntries = []; + + function buildQueryPresets() { + var select = document.getElementById('queryTemplate'); + if (!select || select.getAttribute('data-built') === '1') return; + select.setAttribute('data-built', '1'); + QUERY_PRESETS.forEach(function(preset) { + var option = document.createElement('option'); + option.value = preset.sql; + option.textContent = t(preset.key, preset.key); + select.appendChild(option); + }); + select.addEventListener('change', function() { + if (select.value) document.getElementById('querySql').value = select.value; + }); + } + + function runQuery() { + var sql = (document.getElementById('querySql').value || '').trim(); + var state = document.getElementById('queryStatus'); + var button = document.getElementById('btnRunQuery'); + if (!sql) { state.style.color = '#fff9c4'; state.textContent = t('msg_query_empty', 'Write a statement first.'); return; } + + var maxRows = parseInt(document.getElementById('queryMaxRows').value, 10); + if (isNaN(maxRows) || maxRows < 1) maxRows = 200; + var explain = !!document.getElementById('queryExplain').checked; + + state.style.color = '#90caf9'; + state.textContent = t('msg_query_running', 'Running statement...'); + button.disabled = true; + + apiPost('/Query', { sql: sql, maxRows: maxRows, explain: explain }) + .then(function(response) { return response.json().then(function(body) { return body || {}; }); }) + .then(function(data) { + if (!data.Success) { + state.style.color = '#ef9a9a'; + state.textContent = t('msg_query_error', 'Error: ') + (data.Error || ''); + return; + } + renderQueryResult(data, explain); + pushQueryHistory(sql); + }) + .catch(function(err) { + state.style.color = '#ef9a9a'; + state.textContent = t('msg_network_error', 'Network error: ') + err; + }) + .then(function() { button.disabled = false; }); + } + + function renderQueryResult(data, explain) { + var state = document.getElementById('queryStatus'); + var wrap = document.getElementById('queryResultWrap'); + var head = document.getElementById('queryResultHead'); + var body = document.getElementById('queryResultBody'); + + state.style.color = explain ? '#90caf9' : '#a5d6a7'; + state.textContent = (explain ? t('msg_query_plan_note', 'Execution plan.') + ' ' : '') + + t('msg_query_rows_prefix', 'Rows: ') + data.RowCount + + ' \u00b7 ' + data.DurationMs + t('msg_query_ms_suffix', ' ms') + + (data.Truncated ? t('msg_query_truncated_suffix', ' (truncated)') : ''); + + head.innerHTML = ''; + (data.Columns || []).forEach(function(column) { + var th = document.createElement('th'); + th.textContent = column; + th.style.cssText = 'text-align:left; padding:6px 10px; position:sticky; top:0; background:#131c33; border-bottom:1px solid rgba(124,146,196,.35); white-space:nowrap;'; + head.appendChild(th); + }); + + body.innerHTML = ''; + (data.Rows || []).forEach(function(row, index) { + var tr = document.createElement('tr'); + tr.style.background = index % 2 === 0 ? 'rgba(255,255,255,.02)' : 'transparent'; + row.forEach(function(value) { + var td = document.createElement('td'); + td.style.cssText = 'padding:5px 10px; border-bottom:1px solid rgba(124,146,196,.12); white-space:nowrap; max-width:520px; overflow:hidden; text-overflow:ellipsis;'; + if (value === null || value === undefined) { + td.textContent = t('msg_query_null', 'NULL'); + td.style.color = '#8a94ad'; + td.style.fontStyle = 'italic'; + } else { + td.textContent = value; + } + tr.appendChild(td); + }); + body.appendChild(tr); + }); + + wrap.style.display = (data.Columns && data.Columns.length) ? '' : 'none'; + } + + function pushQueryHistory(sql) { + queryHistoryEntries = queryHistoryEntries.filter(function(entry) { return entry !== sql; }); + queryHistoryEntries.unshift(sql); + if (queryHistoryEntries.length > 10) queryHistoryEntries.length = 10; + renderQueryHistory(); + } + + function renderQueryHistory() { + var wrap = document.getElementById('queryHistoryWrap'); + var list = document.getElementById('queryHistory'); + if (!wrap || !list) return; + list.innerHTML = ''; + if (!queryHistoryEntries.length) { wrap.style.display = 'none'; return; } + + queryHistoryEntries.forEach(function(entry) { + var item = document.createElement('button'); + item.type = 'button'; + item.className = 'raised emby-button'; + item.style.cssText = 'display:block; width:100%; text-align:left; margin:0 0 6px 0; padding:8px 10px; font-size:12px; font-family:ui-monospace,Consolas,monospace;'; + item.textContent = entry.length > 160 ? entry.substring(0, 160) + '\u2026' : entry; + item.addEventListener('click', function() { document.getElementById('querySql').value = entry; }); + list.appendChild(item); + }); + wrap.style.display = ''; + } + + document.getElementById('btnRunQuery').addEventListener('click', runQuery); + document.getElementById('btnClearQuery').addEventListener('click', function() { + document.getElementById('querySql').value = ''; + document.getElementById('queryResultWrap').style.display = 'none'; + document.getElementById('queryStatus').textContent = ''; + }); + document.getElementById('querySql').addEventListener('keydown', function(event) { + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault(); runQuery(); } + }); + function loadStatus() { hideBanner(); apiGet('/Status').then(function(resp) { return resp.json(); }).then(function(data) { From c19b4d3184ceb0cc82eabb7e43848e1d3e59cc84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 02:07:08 -0700 Subject: [PATCH 03/15] fix(jellyfin-12.1): adaptar el proveedor al contrato y al runtime del servidor Resuelve la carga del ensamblado de migraciones en el ALC por defecto, bufferiza los lectores sincronos que Jellyfin deja abiertos, eleva el timeout de comandos, limita la reescritura de MigrateRatingLevels, crea los agregados min/max(uuid) por base de datos e implementa PurgeDatabase (usada al restaurar un backup completo). El logging pasa a registrar traza completa y detalle de PostgreSQL con niveles coherentes, y solo en Postgres-*.log. --- .../20260915211546_Jellyfin121.Designer.cs | 1774 +++++++++++++++++ .../Migrations/20260915211546_Jellyfin121.cs | 560 ++++++ .../JellyfinDbContextModelSnapshot.cs | 162 +- .../Data/PostgresDatabaseProvider.cs | 301 ++- .../Data/PostgresDesignTimeDbFactory.cs | 4 +- ...ellyfin.Database.Providers.Postgres.csproj | 24 +- .../Logging/PostgresLog.cs | 73 +- .../PluginConfiguration.cs | 18 +- .../PostgresPlugin.cs | 14 +- .../Services/DbErrorLoggingInterceptor.cs | 133 +- .../Services/ExportToSqliteService.cs | 238 +-- .../Services/HealthCheckService.cs | 49 +- .../Services/HomeQueryCacheInterceptor.cs | 132 +- .../Jellyfin121MigrationInterceptor.cs | 99 + .../Services/MaintenanceBackupService.cs | 210 +- .../Services/MaintenanceService.cs | 12 +- .../Services/MigrationAssemblyResolver.cs | 132 ++ .../Services/MigrationCodeMigrations.cs | 109 +- .../Services/MigrationEngine.cs | 308 +-- .../Services/MigrationSchemaPreparer.cs | 76 +- .../Services/MigrationService.cs | 39 +- .../Services/MigrationTableCopier.cs | 171 +- .../Services/Models/MigrationOptions.cs | 22 + .../Services/SqliteCompatibilityBootstrap.cs | 160 ++ 24 files changed, 4124 insertions(+), 696 deletions(-) create mode 100644 Jellyfin.Database.Providers.Postgres/Data/Migrations/20260915211546_Jellyfin121.Designer.cs create mode 100644 Jellyfin.Database.Providers.Postgres/Data/Migrations/20260915211546_Jellyfin121.cs create mode 100644 Jellyfin.Database.Providers.Postgres/Services/Jellyfin121MigrationInterceptor.cs create mode 100644 Jellyfin.Database.Providers.Postgres/Services/MigrationAssemblyResolver.cs create mode 100644 Jellyfin.Database.Providers.Postgres/Services/Models/MigrationOptions.cs create mode 100644 Jellyfin.Database.Providers.Postgres/Services/SqliteCompatibilityBootstrap.cs diff --git a/Jellyfin.Database.Providers.Postgres/Data/Migrations/20260915211546_Jellyfin121.Designer.cs b/Jellyfin.Database.Providers.Postgres/Data/Migrations/20260915211546_Jellyfin121.Designer.cs new file mode 100644 index 0000000..33179b7 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Data/Migrations/20260915211546_Jellyfin121.Designer.cs @@ -0,0 +1,1774 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Jellyfin.Database.Providers.Postgres.Data.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260915211546_Jellyfin121")] + partial class Jellyfin121 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndHour") + .HasColumnType("double precision"); + + b.Property("StartHour") + .HasColumnType("double precision"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LogSeverity") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ParentItemId") + .HasColumnType("uuid"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("Codec") + .HasColumnType("text"); + + b.Property("CodecTag") + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("Filename") + .HasColumnType("text"); + + b.Property("MimeType") + .HasColumnType("text"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Album") + .HasColumnType("text"); + + b.Property("AlbumArtists") + .HasColumnType("text"); + + b.Property("Artists") + .HasColumnType("text"); + + b.Property("Audio") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CleanName") + .HasColumnType("text"); + + b.Property("CommunityRating") + .HasColumnType("real"); + + b.Property("CriticRating") + .HasColumnType("real"); + + b.Property("CustomRating") + .HasColumnType("text"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastMediaAdded") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastRefreshed") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastSaved") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeTitle") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasColumnType("text"); + + b.Property("ExternalSeriesId") + .HasColumnType("text"); + + b.Property("ExternalServiceId") + .HasColumnType("text"); + + b.Property("ExtraType") + .HasColumnType("integer"); + + b.Property("ForcedSortName") + .HasColumnType("text"); + + b.Property("Genres") + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("IndexNumber") + .HasColumnType("integer"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("integer"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("integer"); + + b.Property("IsFolder") + .HasColumnType("boolean"); + + b.Property("IsInMixedFolder") + .HasColumnType("boolean"); + + b.Property("IsLocked") + .HasColumnType("boolean"); + + b.Property("IsMovie") + .HasColumnType("boolean"); + + b.Property("IsRepeat") + .HasColumnType("boolean"); + + b.Property("IsSeries") + .HasColumnType("boolean"); + + b.Property("IsVirtualItem") + .HasColumnType("boolean"); + + b.Property("LUFS") + .HasColumnType("real"); + + b.Property("MediaType") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NormalizationGain") + .HasColumnType("real"); + + b.Property("OfficialRating") + .HasColumnType("text"); + + b.Property("OriginalLanguage") + .HasColumnType("text"); + + b.Property("OriginalTitle") + .HasColumnType("text"); + + b.Property("Overview") + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ParentIndexNumber") + .HasColumnType("integer"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("text"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("text"); + + b.Property("PremiereDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PresentationUniqueKey") + .HasColumnType("text"); + + b.Property("PrimaryVersionId") + .HasColumnType("uuid"); + + b.Property("ProductionLocations") + .HasColumnType("text"); + + b.Property("ProductionYear") + .HasColumnType("integer"); + + b.Property("RunTimeTicks") + .HasColumnType("bigint"); + + b.Property("SeasonId") + .HasColumnType("uuid"); + + b.Property("SeasonName") + .HasColumnType("text"); + + b.Property("SeriesId") + .HasColumnType("uuid"); + + b.Property("SeriesName") + .HasColumnType("text"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("text"); + + b.Property("ShowId") + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.Property("SortName") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Studios") + .HasColumnType("text"); + + b.Property("Tagline") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("TopParentId") + .HasColumnType("uuid"); + + b.Property("TotalBitrate") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UnratedType") + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "SortName"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Blurhash") + .HasColumnType("bytea"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("ImageType") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ChapterIndex") + .HasColumnType("integer"); + + b.Property("ImageDateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("ImagePath") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("StartPositionTicks") + .HasColumnType("bigint"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChromecastVersion") + .HasColumnType("integer"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("boolean"); + + b.Property("IndexBy") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ScrollDirection") + .HasColumnType("integer"); + + b.Property("ShowBackdrop") + .HasColumnType("boolean"); + + b.Property("ShowSidebar") + .HasColumnType("boolean"); + + b.Property("SkipBackwardLength") + .HasColumnType("integer"); + + b.Property("SkipForwardLength") + .HasColumnType("integer"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayPreferencesId") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IndexBy") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("RememberIndexing") + .HasColumnType("boolean"); + + b.Property("RememberSorting") + .HasColumnType("boolean"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ViewType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("bigint[]"); + + b.Property("TotalDuration") + .HasColumnType("bigint"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("ChildId") + .HasColumnType("uuid"); + + b.Property("ChildType") + .HasColumnType("integer"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EndTicks") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartTicks") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("StreamIndex") + .HasColumnType("integer"); + + b.Property("AspectRatio") + .HasColumnType("text"); + + b.Property("AverageFrameRate") + .HasColumnType("real"); + + b.Property("BitDepth") + .HasColumnType("integer"); + + b.Property("BitRate") + .HasColumnType("integer"); + + b.Property("BlPresentFlag") + .HasColumnType("integer"); + + b.Property("ChannelLayout") + .HasColumnType("text"); + + b.Property("Channels") + .HasColumnType("integer"); + + b.Property("Codec") + .HasColumnType("text"); + + b.Property("CodecTag") + .HasColumnType("text"); + + b.Property("CodecTimeBase") + .HasColumnType("text"); + + b.Property("ColorPrimaries") + .HasColumnType("text"); + + b.Property("ColorSpace") + .HasColumnType("text"); + + b.Property("ColorTransfer") + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("integer"); + + b.Property("DvLevel") + .HasColumnType("integer"); + + b.Property("DvProfile") + .HasColumnType("integer"); + + b.Property("DvVersionMajor") + .HasColumnType("integer"); + + b.Property("DvVersionMinor") + .HasColumnType("integer"); + + b.Property("ElPresentFlag") + .HasColumnType("integer"); + + b.Property("Hdr10PlusPresentFlag") + .HasColumnType("boolean"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("IsAnamorphic") + .HasColumnType("boolean"); + + b.Property("IsAvc") + .HasColumnType("boolean"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsExternal") + .HasColumnType("boolean"); + + b.Property("IsForced") + .HasColumnType("boolean"); + + b.Property("IsHearingImpaired") + .HasColumnType("boolean"); + + b.Property("IsInterlaced") + .HasColumnType("boolean"); + + b.Property("IsOriginal") + .HasColumnType("boolean"); + + b.Property("KeyFrames") + .HasColumnType("text"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("Level") + .HasColumnType("real"); + + b.Property("NalLengthSize") + .HasColumnType("text"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("PixelFormat") + .HasColumnType("text"); + + b.Property("Profile") + .HasColumnType("text"); + + b.Property("RealFrameRate") + .HasColumnType("real"); + + b.Property("RefFrames") + .HasColumnType("integer"); + + b.Property("Rotation") + .HasColumnType("integer"); + + b.Property("RpuPresentFlag") + .HasColumnType("integer"); + + b.Property("SampleRate") + .HasColumnType("integer"); + + b.Property("StreamType") + .HasColumnType("integer"); + + b.Property("TimeBase") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamType", "ItemId", "Language", "IsExternal"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PersonType") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("PeopleId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("text"); + + b.Property("ListOrder") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.HasIndex("PeopleId", "ItemId"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique(); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("character varying(65535)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique(); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastActivity") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastActivity") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CustomName") + .HasColumnType("text"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Width") + .HasColumnType("integer"); + + b.Property("Bandwidth") + .HasColumnType("integer"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Interval") + .HasColumnType("integer"); + + b.Property("ThumbnailCount") + .HasColumnType("integer"); + + b.Property("TileHeight") + .HasColumnType("integer"); + + b.Property("TileWidth") + .HasColumnType("integer"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DisplayCollectionsView") + .HasColumnType("boolean"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("boolean"); + + b.Property("EnableAutoLogin") + .HasColumnType("boolean"); + + b.Property("EnableLocalPassword") + .HasColumnType("boolean"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("boolean"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("boolean"); + + b.Property("HidePlayedInLatest") + .HasColumnType("boolean"); + + b.Property("InternalId") + .HasColumnType("bigint"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("integer"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("integer"); + + b.Property("MaxActiveSessions") + .HasColumnType("integer"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("integer"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("integer"); + + b.Property("MustUpdatePassword") + .HasColumnType("boolean"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("character varying(65535)"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("boolean"); + + b.Property("RememberAudioSelections") + .HasColumnType("boolean"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("boolean"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SubtitleMode") + .HasColumnType("integer"); + + b.Property("SyncPlayAccess") + .HasColumnType("integer"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CustomDataKey") + .HasColumnType("text"); + + b.Property("AudioStreamIndex") + .HasColumnType("integer"); + + b.Property("IsFavorite") + .HasColumnType("boolean"); + + b.Property("LastPlayedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Likes") + .HasColumnType("boolean"); + + b.Property("PlayCount") + .HasColumnType("integer"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("bigint"); + + b.Property("Played") + .HasColumnType("boolean"); + + b.Property("Rating") + .HasColumnType("double precision"); + + b.Property("RetentionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("integer"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Data/Migrations/20260915211546_Jellyfin121.cs b/Jellyfin.Database.Providers.Postgres/Data/Migrations/20260915211546_Jellyfin121.cs new file mode 100644 index 0000000..7093617 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Data/Migrations/20260915211546_Jellyfin121.cs @@ -0,0 +1,560 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Postgres.Data.Migrations +{ + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1861", Justification = "EF migration operations execute once and follow the scaffolded array format.")] + public partial class Jellyfin121 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UserData_UserId", + table: "UserData"); + + migrationBuilder.DropIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences"); + + migrationBuilder.DropIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions"); + + migrationBuilder.DropIndex( + name: "IX_PeopleBaseItemMap_PeopleId", + table: "PeopleBaseItemMap"); + + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamIndex", + table: "MediaStreamInfos"); + + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamIndex_StreamType", + table: "MediaStreamInfos"); + + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamIndex_StreamType_Language", + table: "MediaStreamInfos"); + + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamType", + table: "MediaStreamInfos"); + + migrationBuilder.DropIndex( + name: "IX_ItemValues_Type_Value", + table: "ItemValues"); + + migrationBuilder.DropIndex( + name: "IX_Devices_DeviceId", + table: "Devices"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_Id_Type_IsFolder_IsVirtualItem", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItemProviders_ProviderId_ProviderValue_ItemId", + table: "BaseItemProviders"); + + migrationBuilder.DropIndex( + name: "IX_BaseItemImageInfos_ItemId", + table: "BaseItemImageInfos"); + + migrationBuilder.Sql( + """ + DELETE FROM "Permissions" WHERE "UserId" IS NULL OR "UserId" NOT IN (SELECT "Id" FROM "Users"); + DELETE FROM "Preferences" WHERE "UserId" IS NULL OR "UserId" NOT IN (SELECT "Id" FROM "Users"); + """); + migrationBuilder.DropColumn( + name: "Preference_Preferences_Guid", + table: "Preferences"); + + migrationBuilder.DropColumn( + name: "Permission_Permissions_Guid", + table: "Permissions"); + + // These are unrelated fields; a rename would turn legacy IDs into a language code. + migrationBuilder.DropColumn(name: "ExtraIds", table: "BaseItems"); + migrationBuilder.AddColumn(name: "OriginalLanguage", table: "BaseItems", type: "text", nullable: true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Preferences", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Permissions", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "IsOriginal", + table: "MediaStreamInfos", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.Sql( + """ + ALTER TABLE "BaseItems" ALTER COLUMN "PrimaryVersionId" TYPE uuid + USING CASE WHEN replace("PrimaryVersionId", '-', '') ~ '^[0-9a-fA-F]{32}$' + THEN NULLIF("PrimaryVersionId"::uuid, '00000000-0000-0000-0000-000000000000'::uuid) + ELSE NULL END; + """); + + migrationBuilder.Sql( + """ + ALTER TABLE "BaseItems" ALTER COLUMN "OwnerId" TYPE uuid + USING CASE WHEN replace("OwnerId", '-', '') ~ '^[0-9a-fA-F]{32}$' + THEN NULLIF("OwnerId"::uuid, '00000000-0000-0000-0000-000000000000'::uuid) + ELSE NULL END; + """); + + migrationBuilder.CreateTable( + name: "LinkedChildren", + columns: table => new + { + ParentId = table.Column(type: "uuid", nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + ChildId = table.Column(type: "uuid", nullable: false), + ChildType = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LinkedChildren", x => new { x.ParentId, x.SortOrder }); + table.ForeignKey( + name: "FK_LinkedChildren_BaseItems_ChildId", + column: x => x.ChildId, + principalTable: "BaseItems", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_LinkedChildren_BaseItems_ParentId", + column: x => x.ParentId, + principalTable: "BaseItems", + principalColumn: "Id"); + }); + + migrationBuilder.UpdateData( + table: "BaseItems", + keyColumn: "Id", + keyValue: new Guid("00000000-0000-0000-0000-000000000001"), + columns: new[] { "Name", "OwnerId", "PrimaryVersionId" }, + values: new object[] { "This is a placeholder item for UserData that has been detached from its original item", null, null }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_UserId_IsFavorite_ItemId", + table: "UserData", + columns: new[] { "UserId", "IsFavorite", "ItemId" }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_UserId_ItemId_LastPlayedDate", + table: "UserData", + columns: new[] { "UserId", "ItemId", "LastPlayedDate" }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_UserId_Played_ItemId", + table: "UserData", + columns: new[] { "UserId", "Played", "ItemId" }); + + migrationBuilder.CreateIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences", + columns: new[] { "UserId", "Kind" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions", + columns: new[] { "UserId", "Kind" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PeopleBaseItemMap_PeopleId_ItemId", + table: "PeopleBaseItemMap", + columns: new[] { "PeopleId", "ItemId" }); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal", + table: "MediaStreamInfos", + columns: new[] { "StreamType", "ItemId", "Language", "IsExternal" }); + + // Preserve item associations while consolidating values before enforcing uniqueness. + migrationBuilder.Sql( + """ + CREATE TEMP TABLE "PostgresItemValueDuplicates" ON COMMIT DROP AS + SELECT "ItemValueId", first_value("ItemValueId") OVER ( + PARTITION BY "Type", "Value" ORDER BY "ItemValueId") AS "KeepId" + FROM "ItemValues"; + INSERT INTO "ItemValuesMap" ("ItemValueId", "ItemId") + SELECT d."KeepId", m."ItemId" FROM "ItemValuesMap" m + JOIN "PostgresItemValueDuplicates" d ON d."ItemValueId" = m."ItemValueId" + WHERE d."ItemValueId" <> d."KeepId" + ON CONFLICT DO NOTHING; + DELETE FROM "ItemValues" WHERE "ItemValueId" IN ( + SELECT "ItemValueId" FROM "PostgresItemValueDuplicates" WHERE "ItemValueId" <> "KeepId"); + """); + migrationBuilder.CreateIndex( + name: "IX_ItemValues_Type_Value", + table: "ItemValues", + columns: new[] { "Type", "Value" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_ExtraType_OwnerId", + table: "BaseItems", + columns: new[] { "ExtraType", "OwnerId" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Name", + table: "BaseItems", + column: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_OwnerId", + table: "BaseItems", + column: "OwnerId"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_PrimaryVersionId", + table: "BaseItems", + column: "PrimaryVersionId", + filter: "\"PrimaryVersionId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_SeasonId", + table: "BaseItems", + column: "SeasonId"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_SeriesId", + table: "BaseItems", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_SeriesName", + table: "BaseItems", + column: "SeriesName"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_TopParentId_IsFolder_IsVirtualItem_DateCreated", + table: "BaseItems", + columns: new[] { "TopParentId", "IsFolder", "IsVirtualItem", "DateCreated" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_TopParentId_MediaType_IsVirtualItem_DateCreated", + table: "BaseItems", + columns: new[] { "TopParentId", "MediaType", "IsVirtualItem", "DateCreated" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_TopParentId_Type_IsVirtualItem", + table: "BaseItems", + columns: new[] { "TopParentId", "Type", "IsVirtualItem" }, + filter: "\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_TopParentId_Type_IsVirtualItem_DateCreated", + table: "BaseItems", + columns: new[] { "TopParentId", "Type", "IsVirtualItem", "DateCreated" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Type_CleanName", + table: "BaseItems", + columns: new[] { "Type", "CleanName" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_ParentIndexNumbe~", + table: "BaseItems", + columns: new[] { "Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Type_TopParentId_SortName", + table: "BaseItems", + columns: new[] { "Type", "TopParentId", "SortName" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItemProviders_ProviderId_ItemId_ProviderValue", + table: "BaseItemProviders", + columns: new[] { "ProviderId", "ItemId", "ProviderValue" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItemImageInfos_ItemId_ImageType", + table: "BaseItemImageInfos", + columns: new[] { "ItemId", "ImageType" }); + + migrationBuilder.CreateIndex( + name: "IX_LinkedChildren_ChildId_ChildType", + table: "LinkedChildren", + columns: new[] { "ChildId", "ChildType" }); + + migrationBuilder.CreateIndex( + name: "IX_LinkedChildren_ParentId_ChildType", + table: "LinkedChildren", + columns: new[] { "ParentId", "ChildType" }); + + // Preserve orphan extras using the same placeholder as Jellyfin's SQLite migration. + migrationBuilder.Sql( + """ + UPDATE "BaseItems" SET "OwnerId" = NULL + WHERE "OwnerId" = '00000000-0000-0000-0000-000000000001'; + UPDATE "BaseItems" SET "OwnerId" = '00000000-0000-0000-0000-000000000001' + WHERE "OwnerId" IS NOT NULL AND "OwnerId" NOT IN (SELECT "Id" FROM "BaseItems"); + """); + migrationBuilder.AddForeignKey( + name: "FK_BaseItems_BaseItems_OwnerId", + table: "BaseItems", + column: "OwnerId", + principalTable: "BaseItems", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_BaseItems_BaseItems_OwnerId", + table: "BaseItems"); + + migrationBuilder.DropTable( + name: "LinkedChildren"); + + migrationBuilder.DropIndex( + name: "IX_UserData_UserId_IsFavorite_ItemId", + table: "UserData"); + + migrationBuilder.DropIndex( + name: "IX_UserData_UserId_ItemId_LastPlayedDate", + table: "UserData"); + + migrationBuilder.DropIndex( + name: "IX_UserData_UserId_Played_ItemId", + table: "UserData"); + + migrationBuilder.DropIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences"); + + migrationBuilder.DropIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions"); + + migrationBuilder.DropIndex( + name: "IX_PeopleBaseItemMap_PeopleId_ItemId", + table: "PeopleBaseItemMap"); + + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal", + table: "MediaStreamInfos"); + + migrationBuilder.DropIndex( + name: "IX_ItemValues_Type_Value", + table: "ItemValues"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_ExtraType_OwnerId", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_Name", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_OwnerId", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_PrimaryVersionId", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_SeasonId", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_SeriesId", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_SeriesName", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_TopParentId_IsFolder_IsVirtualItem_DateCreated", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_TopParentId_MediaType_IsVirtualItem_DateCreated", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_TopParentId_Type_IsVirtualItem", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_TopParentId_Type_IsVirtualItem_DateCreated", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_Type_CleanName", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_ParentIndexNumbe~", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_Type_TopParentId_SortName", + table: "BaseItems"); + + migrationBuilder.DropIndex( + name: "IX_BaseItemProviders_ProviderId_ItemId_ProviderValue", + table: "BaseItemProviders"); + + migrationBuilder.DropIndex( + name: "IX_BaseItemImageInfos_ItemId_ImageType", + table: "BaseItemImageInfos"); + + migrationBuilder.DropColumn( + name: "IsOriginal", + table: "MediaStreamInfos"); + + migrationBuilder.DropColumn(name: "OriginalLanguage", table: "BaseItems"); + migrationBuilder.AddColumn(name: "ExtraIds", table: "BaseItems", type: "text", nullable: true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Preferences", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AddColumn( + name: "Preference_Preferences_Guid", + table: "Preferences", + type: "uuid", + nullable: true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Permissions", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AddColumn( + name: "Permission_Permissions_Guid", + table: "Permissions", + type: "uuid", + nullable: true); + + migrationBuilder.AlterColumn( + name: "PrimaryVersionId", + table: "BaseItems", + type: "text", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "OwnerId", + table: "BaseItems", + type: "text", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.UpdateData( + table: "BaseItems", + keyColumn: "Id", + keyValue: new Guid("00000000-0000-0000-0000-000000000001"), + columns: new[] { "Name", "OwnerId", "PrimaryVersionId" }, + values: new object[] { "This is a placeholder item for UserData that has been detacted from its original item", null, null }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_UserId", + table: "UserData", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences", + columns: new[] { "UserId", "Kind" }, + unique: true, + filter: "\"UserId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions", + columns: new[] { "UserId", "Kind" }, + unique: true, + filter: "\"UserId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_PeopleBaseItemMap_PeopleId", + table: "PeopleBaseItemMap", + column: "PeopleId"); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamIndex", + table: "MediaStreamInfos", + column: "StreamIndex"); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamIndex_StreamType", + table: "MediaStreamInfos", + columns: new[] { "StreamIndex", "StreamType" }); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamIndex_StreamType_Language", + table: "MediaStreamInfos", + columns: new[] { "StreamIndex", "StreamType", "Language" }); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamType", + table: "MediaStreamInfos", + column: "StreamType"); + + migrationBuilder.CreateIndex( + name: "IX_ItemValues_Type_Value", + table: "ItemValues", + columns: new[] { "Type", "Value" }); + + migrationBuilder.CreateIndex( + name: "IX_Devices_DeviceId", + table: "Devices", + column: "DeviceId"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Id_Type_IsFolder_IsVirtualItem", + table: "BaseItems", + columns: new[] { "Id", "Type", "IsFolder", "IsVirtualItem" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItemProviders_ProviderId_ProviderValue_ItemId", + table: "BaseItemProviders", + columns: new[] { "ProviderId", "ProviderValue", "ItemId" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItemImageInfos_ItemId", + table: "BaseItemImageInfos", + column: "ItemId"); + } + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Data/Migrations/JellyfinDbContextModelSnapshot.cs b/Jellyfin.Database.Providers.Postgres/Data/Migrations/JellyfinDbContextModelSnapshot.cs index 3343b8b..4fa740c 100644 --- a/Jellyfin.Database.Providers.Postgres/Data/Migrations/JellyfinDbContextModelSnapshot.cs +++ b/Jellyfin.Database.Providers.Postgres/Data/Migrations/JellyfinDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("ProductVersion", "10.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -208,9 +208,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ExternalServiceId") .HasColumnType("text"); - b.Property("ExtraIds") - .HasColumnType("text"); - b.Property("ExtraType") .HasColumnType("integer"); @@ -268,14 +265,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("OfficialRating") .HasColumnType("text"); + b.Property("OriginalLanguage") + .HasColumnType("text"); + b.Property("OriginalTitle") .HasColumnType("text"); b.Property("Overview") .HasColumnType("text"); - b.Property("OwnerId") - .HasColumnType("text"); + b.Property("OwnerId") + .HasColumnType("uuid"); b.Property("ParentId") .HasColumnType("uuid"); @@ -298,8 +298,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PresentationUniqueKey") .HasColumnType("text"); - b.Property("PrimaryVersionId") - .HasColumnType("text"); + b.Property("PrimaryVersionId") + .HasColumnType("uuid"); b.Property("ProductionLocations") .HasColumnType("text"); @@ -364,26 +364,54 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + b.HasIndex("ParentId"); b.HasIndex("Path"); b.HasIndex("PresentationUniqueKey"); + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + b.HasIndex("TopParentId", "Id"); + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + b.HasIndex("Type", "TopParentId", "Id"); b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); - b.HasIndex("Type", "TopParentId", "StartDate"); + b.HasIndex("Type", "TopParentId", "SortName"); - b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + b.HasIndex("Type", "TopParentId", "StartDate"); b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); @@ -403,7 +431,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) IsRepeat = false, IsSeries = false, IsVirtualItem = false, - Name = "This is a placeholder item for UserData that has been detacted from its original item", + Name = "This is a placeholder item for UserData that has been detached from its original item", Type = "PLACEHOLDER" }); }); @@ -438,7 +466,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("ItemId"); + b.HasIndex("ItemId", "ImageType"); b.ToTable("BaseItemImageInfos"); }); @@ -472,7 +500,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("ItemId", "ProviderId"); - b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); b.ToTable("BaseItemProviders"); }); @@ -728,7 +756,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Type", "CleanValue"); - b.HasIndex("Type", "Value"); + b.HasIndex("Type", "Value") + .IsUnique(); b.ToTable("ItemValues"); }); @@ -764,6 +793,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("KeyframeData"); }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("ChildId") + .HasColumnType("uuid"); + + b.Property("ChildType") + .HasColumnType("integer"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => { b.Property("Id") @@ -886,6 +938,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsInterlaced") .HasColumnType("boolean"); + b.Property("IsOriginal") + .HasColumnType("boolean"); + b.Property("KeyFrames") .HasColumnType("text"); @@ -936,13 +991,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("ItemId", "StreamIndex"); - b.HasIndex("StreamIndex"); - - b.HasIndex("StreamType"); - - b.HasIndex("StreamIndex", "StreamType"); - - b.HasIndex("StreamIndex", "StreamType", "Language"); + b.HasIndex("StreamType", "ItemId", "Language", "IsExternal"); b.ToTable("MediaStreamInfos"); }); @@ -986,12 +1035,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("ItemId", "PeopleId", "Role"); - b.HasIndex("PeopleId"); - b.HasIndex("ItemId", "ListOrder"); b.HasIndex("ItemId", "SortOrder"); + b.HasIndex("PeopleId", "ItemId"); + b.ToTable("PeopleBaseItemMap"); }); @@ -1006,14 +1055,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Kind") .HasColumnType("integer"); - b.Property("Permission_Permissions_Guid") - .HasColumnType("uuid"); - b.Property("RowVersion") .IsConcurrencyToken() .HasColumnType("bigint"); - b.Property("UserId") + b.Property("UserId") .HasColumnType("uuid"); b.Property("Value") @@ -1022,8 +1068,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); b.HasIndex("UserId", "Kind") - .IsUnique() - .HasFilter("[UserId] IS NOT NULL"); + .IsUnique(); b.ToTable("Permissions"); }); @@ -1039,14 +1084,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Kind") .HasColumnType("integer"); - b.Property("Preference_Preferences_Guid") - .HasColumnType("uuid"); - b.Property("RowVersion") .IsConcurrencyToken() .HasColumnType("bigint"); - b.Property("UserId") + b.Property("UserId") .HasColumnType("uuid"); b.Property("Value") @@ -1057,8 +1099,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); b.HasIndex("UserId", "Kind") - .IsUnique() - .HasFilter("[UserId] IS NOT NULL"); + .IsUnique(); b.ToTable("Preferences"); }); @@ -1143,8 +1184,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("DeviceId"); - b.HasIndex("AccessToken", "DateLastActivity"); b.HasIndex("DeviceId", "DateLastActivity"); @@ -1374,8 +1413,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("ItemId", "UserId", "CustomDataKey"); - b.HasIndex("UserId"); - b.HasIndex("ItemId", "UserId", "IsFavorite"); b.HasIndex("ItemId", "UserId", "LastPlayedDate"); @@ -1384,6 +1421,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ItemId", "UserId", "Played"); + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + b.ToTable("UserData"); }); @@ -1428,12 +1471,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") .WithMany("DirectChildren") .HasForeignKey("ParentId") .OnDelete(DeleteBehavior.Cascade); b.Navigation("DirectParent"); + + b.Navigation("Owner"); }); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => @@ -1556,6 +1606,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Item"); }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => { b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") @@ -1591,7 +1660,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) .WithMany("Permissions") .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); }); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => @@ -1599,7 +1669,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) .WithMany("Preferences") .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); }); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => @@ -1640,10 +1711,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("DirectChildren"); + b.Navigation("Extras"); + b.Navigation("Images"); b.Navigation("ItemValues"); + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + b.Navigation("LockedFields"); b.Navigation("MediaStreams"); @@ -1692,4 +1769,3 @@ protected override void BuildModel(ModelBuilder modelBuilder) } } } - diff --git a/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs b/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs index fe0ffdf..a594d88 100644 --- a/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs +++ b/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs @@ -1,6 +1,10 @@ -using System; +using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; @@ -86,6 +90,9 @@ private static readonly (string Name, string Sql)[] NavigationIndexDefinitions = // Ensures the startup health check runs exactly once across all Initialise calls. private static int _startupHealthCheckFired; + // Ensures the unobserved task handler is attached only once. + private static int _unobservedHandlerRegistered; + // ── Constructor ─────────────────────────────────────────────────────────── /// @@ -127,7 +134,9 @@ public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOpt : null) ?? PostgresPlugin.Instance?.Configuration?.ConnectionString; - Logging.PostgresLog.Warn($"[Provider] Initialise llamado. ConnectionString resuelto: {(string.IsNullOrWhiteSpace(connStr) ? "(vacío)" : "OK")}"); + RegisterUnobservedTaskHandler(); + + Logging.PostgresLog.Info($"[Provider] Initialise llamado: inicio del pipeline de EF Core (connection string {(string.IsNullOrWhiteSpace(connStr) ? "vacía" : "resuelta")})."); if (string.IsNullOrWhiteSpace(connStr)) { @@ -138,12 +147,13 @@ public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOpt // Apply pool tuning and prepared-statement cache from plugin config var config = PostgresPlugin.Instance?.Configuration; + var commandTimeout = config?.CommandTimeout ?? 600; var csb = new NpgsqlConnectionStringBuilder(connStr) { MinPoolSize = config?.MinPoolSize ?? 4, MaxPoolSize = config?.MaxPoolSize ?? 100, MaxAutoPrepare = config?.MaxAutoPrepare ?? 50, - CommandTimeout = config?.CommandTimeout ?? 60, + CommandTimeout = commandTimeout, }; var tunedConnStr = csb.ToString(); @@ -154,22 +164,37 @@ public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOpt tunedConnStr, @"(?i)(Password\s*=)[^;]+", "$1*****"); - Logging.PostgresLog.Warn( - $"[ENGINE] Conexi\u00f3n activa (tuneada): {maskedTuned}"); - Logging.PostgresLog.Warn( + Logging.PostgresLog.Info( + $"[ENGINE] Conexión activa (tuneada): {maskedTuned}"); + Logging.PostgresLog.Info( $"[Provider] Pool: min={csb.MinPoolSize} max={csb.MaxPoolSize} " + $"maxAutoPrepare={csb.MaxAutoPrepare} cmdTimeout={csb.CommandTimeout}s"); options .UseNpgsql( tunedConnStr, - npgsql => npgsql.MigrationsAssembly(typeof(PostgresDatabaseProvider).Assembly.GetName().Name!)) + npgsql => + { + npgsql.MigrationsAssembly(MigrationAssemblyResolver.EnsureRegistered()); + + // Set the timeout on the provider as well, so it does not depend solely on the + // connection string (Jellyfin's startup code migrations need a long one). + if (commandTimeout > 0) + { + npgsql.CommandTimeout(commandTimeout); + } + }) .AddInterceptors( + new Jellyfin121MigrationInterceptor(), new HomeQueryCacheInterceptor(_logger), new UpsertConflictInterceptor(), new DbErrorLoggingInterceptor(), new DateTimeKindNormalizingInterceptor()); + // SQLite-compatibility objects (min/max over uuid) have to exist before Jellyfin runs its + // first query, otherwise home-page queries fail with 'no existe la función min(uuid)'. + SqliteCompatibilityBootstrap.Ensure(tunedConnStr); + // Run the health check in background exactly once, even if Initialise is called // multiple times (EF Core can call it more than once during startup). var schema = config?.Schema ?? "public"; @@ -180,6 +205,9 @@ public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOpt try { await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + // Real state of the target database first: version, size, schema, extensions. + await LogStartupStateAsync(tunedConnStr, schema).ConfigureAwait(false); + // Log timezone so operators can detect UTC/local mismatches. await LogPostgresTimezoneAsync(tunedConnStr).ConfigureAwait(false); @@ -206,7 +234,7 @@ private static async Task LogPostgresTimezoneAsync(string connectionString) var tz = await reader.ReadAsync().ConfigureAwait(false) ? reader.GetString(0) : "(desconocida)"; await reader.NextResultAsync().ConfigureAwait(false); var lc = await reader.ReadAsync().ConfigureAwait(false) ? reader.GetString(0) : "(desconocida)"; - Logging.PostgresLog.Warn($"[ENGINE] PostgreSQL timezone={tz} | lc_time={lc}"); + Logging.PostgresLog.Info($"[ENGINE] PostgreSQL timezone={tz} | lc_time={lc}"); // Check pg_stat_statements availability. await reader.CloseAsync().ConfigureAwait(false); @@ -233,6 +261,90 @@ await extCmd.ExecuteScalarAsync().ConfigureAwait(false), } } + /// + /// Reports the real state of the target database: server version, database, size, schema, table + /// count, search path and installed extensions. This is the block to read first when diagnosing + /// an installation from a log file. + /// + /// Connection string of the active database. + /// Schema the provider works against. + private static async Task LogStartupStateAsync(string connectionString, string schema) + { + try + { + var stopwatch = Stopwatch.StartNew(); + using var pg = new NpgsqlConnection(connectionString); + await pg.OpenAsync().ConfigureAwait(false); + var connectMs = stopwatch.ElapsedMilliseconds; + + string serverVersion; + string database; + string searchPath; + string size; + using (var cmd = new NpgsqlCommand( + "SELECT current_setting('server_version'), current_database(), current_setting('search_path'), " + + "pg_size_pretty(pg_database_size(current_database()));", + pg)) + using (var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false)) + { + if (!await reader.ReadAsync().ConfigureAwait(false)) + { + return; + } + + serverVersion = reader.GetString(0); + database = reader.GetString(1); + searchPath = reader.GetString(2); + size = reader.GetString(3); + } + + long tables; + using (var cmd = new NpgsqlCommand("SELECT count(*) FROM pg_tables WHERE schemaname = @schema;", pg)) + { + cmd.Parameters.AddWithValue("schema", schema); + tables = Convert.ToInt64(await cmd.ExecuteScalarAsync().ConfigureAwait(false), CultureInfo.InvariantCulture); + } + + var extensions = new List(); + using (var cmd = new NpgsqlCommand("SELECT extname FROM pg_extension ORDER BY extname;", pg)) + using (var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false)) + { + while (await reader.ReadAsync().ConfigureAwait(false)) + { + extensions.Add(reader.GetString(0)); + } + } + + Logging.PostgresLog.Info( + $"[ESTADO] PostgreSQL {serverVersion} | base={database} ({size}) | esquema={schema} ({tables} tablas) | conexión en {connectMs} ms"); + Logging.PostgresLog.Info($"[ESTADO] search_path={searchPath} | extensiones={string.Join(", ", extensions)}"); + } + catch (Exception ex) + { + Logging.PostgresLog.Error("[ESTADO] No se pudo leer el estado de la base de datos", ex); + } + } + + /// + /// Captures the exceptions of the background jobs this provider starts. Without this handler they + /// would be swallowed by the task scheduler and a failing job would leave no trace at all. + /// + private static void RegisterUnobservedTaskHandler() + { + if (Interlocked.CompareExchange(ref _unobservedHandlerRegistered, 1, 0) != 0) + { + return; + } + + TaskScheduler.UnobservedTaskException += (_, args) => + { + Logging.PostgresLog.Error( + "[Runtime] Una tarea en segundo plano del plugin falló y su excepción no fue observada", + args.Exception); + args.SetObserved(); + }; + } + private static void LogHealthSummary(HealthCheckResult result) { var findings = result.Findings; @@ -240,9 +352,20 @@ private static void LogHealthSummary(HealthCheckResult result) var warns = findings.Count(static f => f.Severity == HealthSeverity.Warn); var info = findings.Count - errors - warns; - Logging.PostgresLog.Warn( + var summary = $"[HealthCheck] Resumen arranque: {errors} error(es), {warns} advertencia(s), {info} informativo(s) | " + - $"Severidad: {result.OverallSeverity} | {result.DurationMs} ms"); + $"Severidad: {result.OverallSeverity} | {result.DurationMs} ms"; + + // An informational level when everything is fine: otherwise every startup looks like an + // incident and the real warnings get lost in the noise. + if (errors > 0 || warns > 0) + { + Logging.PostgresLog.Warn(summary); + } + else + { + Logging.PostgresLog.Info(summary); + } foreach (var f in findings) { @@ -284,18 +407,134 @@ await RunOptimizationsAsync(connStr, enableSearch: true, enableVacuum: true, _lo /// public Task MigrationBackupFast(CancellationToken cancellationToken) - => throw new NotImplementedException("Use the plugin backup task instead."); + { + var directory = GetMigrationBackupDirectory(); + return new MaintenanceBackupService(_logger).CreateBackupAsync( + GetActiveConnectionString(), + directory, + false, + PostgresPlugin.Instance?.Configuration.PgBinPath, + includeCleanCommands: true, + ct: cancellationToken); + } /// - public Task RestoreBackupFast(string key, CancellationToken cancellationToken) - => throw new NotImplementedException("Use the plugin restore task instead."); + public async Task RestoreBackupFast(string key, CancellationToken cancellationToken) + { + ValidateMigrationBackupPath(key); + await new MaintenanceBackupService(_logger).RestoreBackupAsync( + GetActiveConnectionString(), + key, + PostgresPlugin.Instance?.Configuration.PgBinPath, + true, + cancellationToken).ConfigureAwait(false); + } /// - public Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable? tableNames) - => throw new NotImplementedException(); + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "Table names come from Jellyfin's own EF model and every identifier is validated and quoted by QuoteQualifiedTableName. No user input.")] + public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable? tableNames) + { + ArgumentNullException.ThrowIfNull(dbContext); + ArgumentNullException.ThrowIfNull(tableNames); + + // Jellyfin hands over the table names of its own model, schema qualified when the provider + // defines a default schema ("public.BaseItems"). They cannot be parameters, so each part is + // validated as a plain identifier and quoted before it reaches the statement. + var tables = new List(); + foreach (var tableName in tableNames) + { + if (!string.IsNullOrWhiteSpace(tableName)) + { + tables.Add(QuoteQualifiedTableName(tableName)); + } + } + + if (tables.Count == 0) + { + return; + } + + // One statement for every table: a single TRUNCATE only succeeds when all the foreign keys + // between the listed tables are covered, which is exactly the case here. CASCADE mirrors what + // the SQLite provider does with 'PRAGMA foreign_keys = OFF', so the restore that follows can + // insert the rows back in any order. Only Jellyfin's own tables are listed. + var sql = string.Concat("TRUNCATE TABLE ", string.Join(", ", tables), " RESTART IDENTITY CASCADE;"); + await dbContext.Database.ExecuteSqlRawAsync(sql).ConfigureAwait(false); + Logging.PostgresLog.Info($"[Purge] Base de datos vaciada: {tables.Count} tablas (TRUNCATE ... RESTART IDENTITY CASCADE)."); + } /// - public Task DeleteBackup(string key) => throw new NotImplementedException(); + public Task DeleteBackup(string key) + { + ValidateMigrationBackupPath(key); + File.Delete(key); + return Task.CompletedTask; + } + + /// + /// Quotes a table name coming from EF's model, accepting an optional schema prefix + /// (schema.table) with or without surrounding double quotes. + /// + /// Table name as returned by EF Core. + /// The quoted, schema qualified table name. + private static string QuoteQualifiedTableName(string schemaQualifiedName) + { + var parts = schemaQualifiedName.Split('.', StringSplitOptions.TrimEntries); + if (parts.Length > 2) + { + throw new ArgumentException($"Unsupported table name '{schemaQualifiedName}'.", nameof(schemaQualifiedName)); + } + + var quoted = new string[parts.Length]; + for (var i = 0; i < parts.Length; i++) + { + quoted[i] = MaintenanceService.QuoteIdentifier(RequireSqlIdentifier(parts[i])); + } + + return string.Join('.', quoted); + } + + /// + /// Validates that a name is a plain SQL identifier. Identifiers cannot be parameterised, so + /// anything else is rejected instead of being interpolated (defends against injection). + /// + /// Candidate identifier, possibly wrapped in double quotes. + /// The identifier without its surrounding quotes. + private static string RequireSqlIdentifier(string name) + { + var identifier = name.Trim('"'); + if (identifier.Length == 0) + { + throw new ArgumentException("Empty SQL identifier.", nameof(name)); + } + + foreach (var c in identifier) + { + if (!char.IsAsciiLetterOrDigit(c) && c != '_') + { + throw new ArgumentException($"Unsupported character '{c}' in SQL identifier '{identifier}'.", nameof(name)); + } + } + + return identifier; + } + + private string GetMigrationBackupDirectory() + => Path.Combine((_applicationPaths ?? throw new InvalidOperationException("Application paths are required for migration backups.")).DataPath, "postgres-migration-backups"); + + private void ValidateMigrationBackupPath(string key) + { + if (!string.Equals(Path.GetDirectoryName(Path.GetFullPath(key)), Path.GetFullPath(GetMigrationBackupDirectory()), StringComparison.OrdinalIgnoreCase) + || !string.Equals(Path.GetExtension(key), ".sql", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Backup must be a SQL file in the migration backup directory.", nameof(key)); + } + } + + private string GetActiveConnectionString() + => (_applicationPaths is null ? null : PostgresPlugin.ReadActivePgConnectionString(_applicationPaths)) + ?? PostgresPlugin.Instance?.Configuration.ConnectionString + ?? throw new InvalidOperationException("PostgreSQL connection string is not configured."); // ── Internal optimization entry point ──────────────────────────────────── @@ -316,19 +555,19 @@ internal static async Task RunOptimizationsAsync( ILogger logger, CancellationToken ct) { - Logging.PostgresLog.Warn($"[Optimization] INICIO: enableSearch={enableSearch} enableVacuum={enableVacuum}"); + Logging.PostgresLog.Info($"[Optimization] INICIO: enableSearch={enableSearch} enableVacuum={enableVacuum}"); using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(ct).ConfigureAwait(false); _trgmAvailable = await CheckTrgmAsync(conn, logger, ct).ConfigureAwait(false); - Logging.PostgresLog.Warn($"[Optimization] pg_trgm: {(_trgmAvailable ? "disponible \u2713" : "no disponible \u2717 \u2014 InstantSearch usar\u00e1 ILIKE")}"); + Logging.PostgresLog.Info($"[Optimization] pg_trgm: {(_trgmAvailable ? "disponible \u2713" : "no disponible \u2717 \u2014 InstantSearch usar\u00e1 ILIKE")}"); if (enableSearch && _trgmAvailable) { - Logging.PostgresLog.Warn($"[Optimization] Aplicando {GinIndexDefinitions.Length} \u00edndices GIN trigram..."); + Logging.PostgresLog.Info($"[Optimization] Aplicando {GinIndexDefinitions.Length} \u00edndices GIN trigram..."); await ApplySearchIndexesAsync(conn, logger, ct).ConfigureAwait(false); - Logging.PostgresLog.Warn("[Optimization] \u00cdndices GIN trigram aplicados (IF NOT EXISTS \u2014 instant\u00e1neo si ya exist\u00edan)."); + Logging.PostgresLog.Info("[Optimization] \u00cdndices GIN trigram aplicados (IF NOT EXISTS \u2014 instant\u00e1neo si ya exist\u00edan)."); } else if (enableSearch && !_trgmAvailable) { @@ -337,26 +576,26 @@ internal static async Task RunOptimizationsAsync( // Navigation indexes improve home-page, library-browser and resume performance // regardless of pg_trgm availability. - Logging.PostgresLog.Warn($"[Optimization] Aplicando {NavigationIndexDefinitions.Length} \u00edndices de navegaci\u00f3n parciales..."); + Logging.PostgresLog.Info($"[Optimization] Aplicando {NavigationIndexDefinitions.Length} \u00edndices de navegaci\u00f3n parciales..."); await ApplyNavigationIndexesAsync(conn, logger, ct).ConfigureAwait(false); - Logging.PostgresLog.Warn("[Optimization] \u00cdndices de navegaci\u00f3n aplicados (home-page, library-browser, resume)."); + Logging.PostgresLog.Info("[Optimization] \u00cdndices de navegaci\u00f3n aplicados (home-page, library-browser, resume)."); // Query-planner memory hints (work_mem) for sort-heavy queries. await ApplyServerMemoryTuningAsync(conn, logger, ct).ConfigureAwait(false); - Logging.PostgresLog.Warn("[Optimization] Ajuste de memoria de sesi\u00f3n aplicado (work_mem=16MB)."); + Logging.PostgresLog.Info("[Optimization] Ajuste de memoria de sesi\u00f3n aplicado (work_mem=16MB)."); if (enableVacuum) { - Logging.PostgresLog.Warn("[Optimization] Aplicando autovacuum tuning en tablas cr\u00edticas..."); + Logging.PostgresLog.Info("[Optimization] Aplicando autovacuum tuning en tablas cr\u00edticas..."); await ApplyAutovacuumTuningAsync(conn, logger, ct).ConfigureAwait(false); - Logging.PostgresLog.Warn($"[Optimization] Autovacuum tuning aplicado en: {string.Join(", ", AutovacuumTables)}"); + Logging.PostgresLog.Info($"[Optimization] Autovacuum tuning aplicado en: {string.Join(", ", AutovacuumTables)}"); } // Try to activate pg_stat_statements for slow-query visibility. // Requires pg_stat_statements in shared_preload_libraries (needs restart if not already active). await TryActivateStatStatementsAsync(conn, logger, ct).ConfigureAwait(false); - Logging.PostgresLog.Warn("[Optimization] COMPLETADO. InstantSearch activo, home-page optimizada, autovacuum tuned."); + Logging.PostgresLog.Info("[Optimization] COMPLETADO. InstantSearch activo, home-page optimizada, autovacuum tuned."); } // ── Private helpers ─────────────────────────────────────────────────────── @@ -453,8 +692,12 @@ private static async Task TryExecuteIndexWithRetryAsync( // sql comes from GinIndexDefinitions — hardcoded static array (CA2100). using var cmd = CreateIndexCommand(connection, sql); await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - logger.LogInformation("Index applied: {IndexName}", indexName); - Logging.PostgresLog.Warn($"[Optimization] \u2713 {indexName}"); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Index applied: {IndexName}", indexName); + } + + Logging.PostgresLog.Info($"[Optimization] \u2713 {indexName}"); return; } catch (PostgresException pgEx) when (pgEx.SqlState == "40P01" && attempt < RetryDelays.Length) @@ -520,7 +763,7 @@ await checkCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), using var createCmd = new NpgsqlCommand( "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;", conn); await createCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - Logging.PostgresLog.Warn("[Optimization] pg_stat_statements activado \u2713 (agrega 'pg_stat_statements' a shared_preload_libraries para que persista entre reinicios)."); + Logging.PostgresLog.Info("[Optimization] pg_stat_statements activado \u2713 (agrega 'pg_stat_statements' a shared_preload_libraries para que persista entre reinicios)."); } catch (PostgresException pgEx) when (pgEx.SqlState is "55P02" or "42501") { diff --git a/Jellyfin.Database.Providers.Postgres/Data/PostgresDesignTimeDbFactory.cs b/Jellyfin.Database.Providers.Postgres/Data/PostgresDesignTimeDbFactory.cs index 5da0759..7f2e005 100644 --- a/Jellyfin.Database.Providers.Postgres/Data/PostgresDesignTimeDbFactory.cs +++ b/Jellyfin.Database.Providers.Postgres/Data/PostgresDesignTimeDbFactory.cs @@ -1,5 +1,6 @@ using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Postgres.Services; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Logging.Abstractions; @@ -26,8 +27,7 @@ public JellyfinDbContext CreateDbContext(string[] args) var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseNpgsql( connectionString, - npgsqlOptions => npgsqlOptions.MigrationsAssembly( - typeof(PostgresDesignTimeDbFactory).Assembly.GetName().Name!)); + npgsqlOptions => npgsqlOptions.MigrationsAssembly(MigrationAssemblyResolver.EnsureRegistered())); return new JellyfinDbContext( optionsBuilder.Options, diff --git a/Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj b/Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj index 68960fa..5a782c2 100644 --- a/Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj +++ b/Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj @@ -1,7 +1,7 @@ - + - net9.0 + net10.0 Jellyfin.Database.Providers.Postgres enable true @@ -13,7 +13,7 @@ Set this to your Jellyfin server version. Check https://www.nuget.org/packages/Jellyfin.Controller/ for available versions. --> - 10.11.10 + 12.1.0 @@ -22,29 +22,29 @@ the plugin .dll — the server provides them at runtime via its own assembly loader. --> - runtime + runtime - - runtime + + runtime - runtime + runtime - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - runtime + + + runtime diff --git a/Jellyfin.Database.Providers.Postgres/Logging/PostgresLog.cs b/Jellyfin.Database.Providers.Postgres/Logging/PostgresLog.cs index 13e28c9..5a05cd4 100644 --- a/Jellyfin.Database.Providers.Postgres/Logging/PostgresLog.cs +++ b/Jellyfin.Database.Providers.Postgres/Logging/PostgresLog.cs @@ -2,6 +2,8 @@ using System.Diagnostics; using System.Globalization; using System.IO; +using System.Text; +using Npgsql; namespace Jellyfin.Database.Providers.Postgres.Logging; @@ -17,6 +19,9 @@ internal static class PostgresLog /// Number of days to keep log files before automatic deletion. internal const int RetentionDays = 14; + // Longest value written for a single exception field (PostgreSQL details can be huge). + private const int MaxFieldLength = 400; + private static readonly object Lock = new(); // Default path — overridden at plugin startup via SetLogDirectory(). @@ -114,18 +119,68 @@ public static void Write(string level, string message) /// Writes an error message including exception details. /// Context description. /// The exception to include. - public static void Error(string msg, Exception ex) => - Write( - "ERROR", - string.Format( - CultureInfo.InvariantCulture, - "{0} | {1}: {2}", - msg, - ex.GetType().Name, - ex.Message)); + public static void Error(string msg, Exception ex) => WriteException("ERROR", msg, ex); + + /// Writes a warning including exception details. + /// Context description. + /// The exception to include. + public static void Warn(string msg, Exception ex) => WriteException("WARN ", msg, ex); // ── Private helpers ──────────────────────────────────────────────────── + /// + /// Writes an exception as a lead line with the PostgreSQL detail plus the full stack trace, + /// which is what turns "the export failed" into something that can actually be diagnosed. + /// + /// Log level label. + /// Context description. + /// The exception to describe. + private static void WriteException(string level, string message, Exception ex) + { + var text = new StringBuilder(); + text.Append(message).Append(" | ").Append(Describe(ex)); + text.AppendLine(); + text.Append(Indent(ex.ToString())); + Write(level, text.ToString()); + } + + /// Builds the one-line description of an exception, adding PostgreSQL specific fields. + /// The exception to describe. + /// The description. + private static string Describe(Exception ex) + { + var text = new StringBuilder(string.Concat(ex.GetType().FullName, ": ", ex.Message)); + if (ex is PostgresException postgres) + { + AppendDetail(text, "SqlState", postgres.SqlState); + AppendDetail(text, "Detail", postgres.Detail); + AppendDetail(text, "Hint", postgres.Hint); + AppendDetail(text, "Table", postgres.TableName); + AppendDetail(text, "Column", postgres.ColumnName); + AppendDetail(text, "Constraint", postgres.ConstraintName); + AppendDetail(text, "Where", postgres.Where); + } + + return text.ToString(); + } + + private static void AppendDetail(StringBuilder builder, string label, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + builder.Append(" | ").Append(label).Append('=').Append(Truncate(value)); + } + } + + /// Indents every stack frame so the block reads as one entry. + /// Text to indent. + /// The indented text. + private static string Indent(string text) + => text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\n", "\n ", StringComparison.Ordinal); + + private static string Truncate(string value) + => value.Length <= MaxFieldLength ? value : string.Concat(value.AsSpan(0, MaxFieldLength), "\u2026"); + private static void EnsureDirectory() { try diff --git a/Jellyfin.Database.Providers.Postgres/PluginConfiguration.cs b/Jellyfin.Database.Providers.Postgres/PluginConfiguration.cs index c880bc6..39e19f1 100644 --- a/Jellyfin.Database.Providers.Postgres/PluginConfiguration.cs +++ b/Jellyfin.Database.Providers.Postgres/PluginConfiguration.cs @@ -19,9 +19,16 @@ public class PluginConfiguration : BasePluginConfiguration public string Schema { get; set; } = "public"; /// - /// Gets or sets the EF command timeout in seconds (default 60). + /// Gets or sets the EF command timeout in seconds (default 600). /// - public int CommandTimeout { get; set; } = 60; + /// + /// Jellyfin runs heavyweight code migrations during startup (for example + /// RefreshCleanNamesAndValues or MigrateRatingLevels) that update tens of + /// thousands of rows inside one transaction. A short timeout aborts the migration and forces + /// Jellyfin to roll the database back, so the default is generous; lower it only if the + /// server must fail fast on slow queries. + /// + public int CommandTimeout { get; set; } = 600; /// /// Gets or sets the current migration state. @@ -96,4 +103,11 @@ public class PluginConfiguration : BasePluginConfiguration /// on high-churn tables (UserData, ActivityLogs) to prevent bloat. /// public bool EnableAutovacuumTuning { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the parameter values of a failed database command are + /// written to the plugin log. Off by default because parameters carry user data; enable it only + /// while diagnosing a specific failure (mostly unique/PK violations). + /// + public bool LogCommandParameters { get; set; } } diff --git a/Jellyfin.Database.Providers.Postgres/PostgresPlugin.cs b/Jellyfin.Database.Providers.Postgres/PostgresPlugin.cs index 4c9722a..0d460f8 100644 --- a/Jellyfin.Database.Providers.Postgres/PostgresPlugin.cs +++ b/Jellyfin.Database.Providers.Postgres/PostgresPlugin.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -50,7 +50,7 @@ public PostgresPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSeri @"(?i)(Password\s*=)[^;]+", "$1*****") : "(unknown)"; - PostgresLog.Warn($"[ENGINE] Modo activo: PostgreSQL. Connection: {maskedConn}"); + PostgresLog.Info($"[ENGINE] Modo activo: PostgreSQL. Connection: {maskedConn}"); } else { @@ -58,7 +58,7 @@ public PostgresPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSeri var sqliteSize = System.IO.File.Exists(sqlitePath) ? $"{new System.IO.FileInfo(sqlitePath).Length / 1_048_576.0:F1} MB" : "(no encontrado)"; - PostgresLog.Warn($"[ENGINE] Modo activo: SQLite. Archivo: {sqlitePath} ({sqliteSize})"); + PostgresLog.Info($"[ENGINE] Modo activo: SQLite. Archivo: {sqlitePath} ({sqliteSize})"); } } @@ -103,7 +103,13 @@ public static void WriteDatabaseXml(IApplicationPaths applicationPaths, string c var filePath = Path.Combine(configDir, "database.xml"); // Escape XML special characters in the connection string (passwords can contain &, <, > etc.) - var escapedConnStr = SecurityElementHelper.Escape(connectionString) ?? connectionString; + var connection = new Npgsql.NpgsqlConnectionStringBuilder(connectionString); + if (string.IsNullOrWhiteSpace(connection.SearchPath)) + { + connection.SearchPath = "\"" + (Instance?.Configuration.Schema ?? "public").Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; + } + + var escapedConnStr = SecurityElementHelper.Escape(connection.ConnectionString) ?? connection.ConnectionString; var xml = new StringBuilder(); xml.AppendLine(""); diff --git a/Jellyfin.Database.Providers.Postgres/Services/DbErrorLoggingInterceptor.cs b/Jellyfin.Database.Providers.Postgres/Services/DbErrorLoggingInterceptor.cs index 7c753ad..f837716 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/DbErrorLoggingInterceptor.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/DbErrorLoggingInterceptor.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using System.Data.Common; +using System.Globalization; +using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Providers.Postgres.Logging; @@ -20,6 +23,15 @@ namespace Jellyfin.Database.Providers.Postgres.Services; /// public sealed class DbErrorLoggingInterceptor : DbCommandInterceptor { + // Longest SQL preview written for a failed command (EF Core statements are long, and the head of + // the statement is what identifies it). + private const int MaxSqlPreviewLength = 500; + + // Longest value written for a single command parameter. + private const int MaxParameterLength = 64; + + private const int MaxReportedStates = 200; + // Severity thresholds: only log when the Npgsql/EF Core exception represents // an actual database error, not a normal "no rows" result. private static readonly string[] SilentSqlStates = @@ -28,6 +40,12 @@ public sealed class DbErrorLoggingInterceptor : DbCommandInterceptor "02001", // no_additional_dynamic_result_sets_returned ]; + // Failures whose stack trace has already been written, so a query that breaks on every request + // reports the trace once and a single line afterwards. + private static readonly HashSet ReportedStates = new(StringComparer.Ordinal); + + private static readonly object ReportLock = new(); + /// public override void CommandFailed(DbCommand command, CommandErrorEventData eventData) => LogCommandError(command, eventData.Exception); @@ -60,27 +78,114 @@ private static void LogCommandError(DbCommand command, Exception? exception) } var severity = pgEx.Severity?.ToUpperInvariant(); - var label = severity switch + var isError = severity is "ERROR" or "FATAL" or "PANIC"; + var level = isError ? "ERROR" : "WARN "; + var message = BuildMessage(pgEx, command); + + // The first occurrence carries the full detail (stack trace plus SqlState, table, + // constraint, hint); later occurrences of the same failure stay on one line. + if (ShouldReportFullDetail(pgEx)) + { + if (isError) + { + PostgresLog.Error(message, pgEx); + } + else + { + PostgresLog.Warn(message, pgEx); + } + } + else { - "FATAL" or "PANIC" => "FATAL", - "ERROR" => "ERROR", - _ => "WARN ", - }; - - // Truncate the SQL to 200 chars so the log line stays readable. - var sqlPreview = Truncate(command.CommandText, 200); - PostgresLog.Write( - label, - $"[DB] {pgEx.SqlState} {pgEx.MessageText} | SQL: {sqlPreview}"); + PostgresLog.Write(level, string.Concat(message, " | traza completa ya reportada")); + } } else { - var sqlPreview = Truncate(command.CommandText, 200); - PostgresLog.Error( - $"[DB] Error de base de datos: {exception.GetType().Name}: {exception.Message} | SQL: {sqlPreview}"); + PostgresLog.Error(BuildMessage(exception, command), exception); + } + } + + /// + /// Builds the context line of a failed command: where it came from, the statement and, when the + /// administrator enabled it, the parameter values that actually caused the failure. + /// + /// The failure (PostgreSQL server error or handled exception). + /// The command that failed. + /// The message to log. + private static string BuildMessage(Exception exception, DbCommand command) + { + var text = new StringBuilder("[DB] Comando fallido: "); + if (exception is PostgresException postgres) + { + text.Append(postgres.SqlState).Append(' ').Append(postgres.MessageText); + if (!string.IsNullOrWhiteSpace(postgres.ConstraintName)) + { + text.Append(" | constraint=").Append(postgres.ConstraintName); + } + + if (!string.IsNullOrWhiteSpace(postgres.TableName)) + { + text.Append(" | tabla=").Append(postgres.TableName); + } + } + + text.Append(" | SQL: ").Append(Truncate(command.CommandText, MaxSqlPreviewLength)); + + // Parameter values contain user data, so they are only written when the administrator turns + // the option on; Jellyfin's own sensitive-data logging stays untouched. + if (PostgresPlugin.Instance?.Configuration?.LogCommandParameters == true && command.Parameters.Count > 0) + { + text.Append(" | params: "); + for (var i = 0; i < command.Parameters.Count; i++) + { + var parameter = command.Parameters[i]; + if (i > 0) + { + text.Append(", "); + } + + text.Append(parameter.ParameterName) + .Append('=') + .Append(Truncate(FormatValue(parameter.Value), MaxParameterLength)); + } + } + + return text.ToString(); + } + + private static bool ShouldReportFullDetail(PostgresException exception) + { + var key = string.Concat(exception.SqlState, "|", exception.ConstraintName, "|", exception.MessageText); + lock (ReportLock) + { + if (!ReportedStates.Add(key)) + { + return false; + } + + if (ReportedStates.Count > MaxReportedStates) + { + ReportedStates.Clear(); + } } + + return true; } + private static string FormatValue(object? value) + => value switch + { + null or DBNull => "NULL", + byte[] bytes => string.Create( + CultureInfo.InvariantCulture, + $"byte[{bytes.Length}]"), + Array array => string.Create( + CultureInfo.InvariantCulture, + $"array[{array.Length}]"), + _ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty, + }; + private static string Truncate(string? value, int maxLength) { if (string.IsNullOrEmpty(value)) diff --git a/Jellyfin.Database.Providers.Postgres/Services/ExportToSqliteService.cs b/Jellyfin.Database.Providers.Postgres/Services/ExportToSqliteService.cs index 5466f01..522667f 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/ExportToSqliteService.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/ExportToSqliteService.cs @@ -24,6 +24,9 @@ public sealed class ExportToSqliteService : IDisposable private const int CommitEveryRows = 10_000; private const int MaxLogLines = 500; + // SQLite primary result code for constraint violations (SQLITE_CONSTRAINT). + private const int SqliteConstraintErrorCode = 19; + private static readonly HashSet SkipTables = new(StringComparer.OrdinalIgnoreCase) { "__EFMigrationsHistory", "__EFMigrationsLock" }; @@ -120,7 +123,7 @@ public bool StartExport(string pgConnStr, string sqliteDbPath) _logBuffer.Clear(); } - PostgresLog.Warn($"[Export] INICIO: PostgreSQL → {sqliteDbPath}"); + PostgresLog.Info($"[Export] INICIO: PostgreSQL → {sqliteDbPath}"); _ = Task.Run(async () => { @@ -128,7 +131,7 @@ public bool StartExport(string pgConnStr, string sqliteDbPath) { await RunExportAsync(pgConnStr, sqliteDbPath, CancellationToken.None).ConfigureAwait(false); _isCompleted = true; - PostgresLog.Warn($"[Export] COMPLETADA: {_exportedRows:N0} filas exportadas → {sqliteDbPath}"); + PostgresLog.Info($"[Export] COMPLETADA: {_exportedRows:N0} filas exportadas → {sqliteDbPath}"); } catch (Exception ex) { @@ -174,11 +177,11 @@ private async Task RunExportAsync(string pgConnStr, string sqliteDbPath, Cancell var tables = await ExportHelpers.GetUserTablesAsync(pgConn, ct).ConfigureAwait(false); tables = tables.Where(t => !SkipTables.Contains(t)).ToList(); Log($"Tablas a exportar: {tables.Count}"); - PostgresLog.Warn($"[Export] Tablas a exportar: {tables.Count}"); + PostgresLog.Info($"[Export] Tablas a exportar: {tables.Count}"); _totalRows = await ExportHelpers.EstimateTotalRowsAsync(pgConn, tables, ct).ConfigureAwait(false); Log($"Filas estimadas: {_totalRows:N0}"); - PostgresLog.Warn($"[Export] Filas estimadas: {_totalRows:N0}"); + PostgresLog.Info($"[Export] Filas estimadas: {_totalRows:N0}"); _percentComplete = 2; // File existence and schema are guaranteed by the controller before StartExport is called. @@ -209,10 +212,7 @@ private async Task RunExportAsync(string pgConnStr, string sqliteDbPath, Cancell // FOREIGN KEY constraint failures when Jellyfin operates in SQLite mode. await CleanOrphanedForeignKeysAsync(sqliteConn, ct).ConfigureAwait(false); - // Pre-mark all Jellyfin code migrations as applied so Jellyfin does not - // attempt to run legacy routines (e.g. RemoveDuplicateExtras → TypedBaseItems) - // when switching back to SQLite mode. - await PreMarkCodeMigrationsAsync(sqliteConn, ct).ConfigureAwait(false); + await CopyCodeMigrationsAsync(pgConn, sqliteConn, ct).ConfigureAwait(false); _percentComplete = 100; var fileSize = new FileInfo(sqliteDbPath).Length; @@ -248,6 +248,13 @@ private async Task ExportTableAsync( schema = pgSchema.Where(c => sqliteColSet.Contains(c.Name)).ToList(); } + // Primary-key columns are copied verbatim: text key columns are case-sensitive + // in both engines, so normalising their case would merge distinct rows. + var keyColumns = await ExportHelpers.GetPrimaryKeyColumnsAsync(pgConn, table, ct).ConfigureAwait(false); + var isKeyColumn = schema + .Select(c => keyColumns.Contains(c.Name, StringComparer.OrdinalIgnoreCase)) + .ToArray(); + // table comes from pg_tables (system catalog), not user input (CA2100). long rowCount; using var countCmd = CreateCountCommand(pgConn, table); @@ -261,7 +268,7 @@ await countCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), } Log($" → {table} ({rowCount:N0} filas)"); - PostgresLog.Warn($"[Export] → {table} ({rowCount:N0} filas)"); + PostgresLog.Info($"[Export] → {table} ({rowCount:N0} filas)"); await ExportHelpers.ExecSqliteAsync(sqliteConn, $"""DELETE FROM "{table}";""", ct).ConfigureAwait(false); @@ -284,6 +291,7 @@ await countCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), SqliteTransaction? activeTx = (SqliteTransaction)await sqliteConn.BeginTransactionAsync(ct).ConfigureAwait(false); insertCmd.Transaction = activeTx; long batchCount = 0; + long rowsRead = 0; try { @@ -291,11 +299,21 @@ await countCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), { for (var col = 0; col < schema.Count; col++) { - parameters[col].Value = ConvertForSqlite(reader.GetValue(col)); + parameters[col].Value = ConvertForSqlite(reader.GetValue(col), isKeyColumn[col]); + } + + try + { + await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + catch (SqliteException ex) when (ex.SqliteErrorCode == SqliteConstraintErrorCode) + { + throw new InvalidOperationException( + BuildConstraintError(table, schema, parameters, isKeyColumn, rowsRead + 1, ex), ex); } - await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); batchCount++; + rowsRead++; _exportedRows++; if (batchCount >= CommitEveryRows) @@ -316,6 +334,48 @@ await countCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), } } + /// + /// Builds an actionable error for a SQLite constraint violation, including the + /// primary-key values of the row that could not be inserted. + /// + /// Table being exported. + /// Columns being exported, in insert order. + /// Insert parameters holding the offending row values. + /// Flags the primary-key columns within . + /// 1-based row number within the table. + /// The SQLite error being reported. + /// Error message with the conflicting primary key. + private static string BuildConstraintError( + string table, + List schema, + List parameters, + bool[] isKeyColumn, + long rowNumber, + SqliteException inner) + { + var key = new List(); + for (var i = 0; i < schema.Count; i++) + { + if (isKeyColumn[i]) + { + key.Add(string.Format( + CultureInfo.InvariantCulture, + "{0}={1}", + schema[i].Name, + parameters[i].Value ?? "NULL")); + } + } + + return string.Format( + CultureInfo.InvariantCulture, + "Failed to export table '{0}' (row {1}): {2} Conflicting primary key: {3}. " + + "Verify the source rows in PostgreSQL; two rows may differ only by letter case.", + table, + rowNumber, + inner.Message, + key.Count > 0 ? string.Join(", ", key) : "(unknown)"); + } + // ── Value conversion ────────────────────────────────────────────────────── // GUID format note: Jellyfin's native SQLite stores GUIDs in UPPERCASE @@ -323,7 +383,12 @@ await countCmd.ExecuteScalarAsync(ct).ConfigureAwait(false), // in lowercase. We normalise to UPPERCASE on export so that Jellyfin's // case-sensitive internal lookups (UserManager, DeviceManager, etc.) work // correctly when switching back to SQLite mode. - private static object ConvertForSqlite(object? value) => value switch + // + // Primary-key columns are never case-normalised: a text key column such as + // UserData.CustomDataKey is case-sensitive, and rows like 'AE19...' / 'ae19...' + // are legitimate distinct rows. Uppercasing them merges both rows and violates + // the primary key, aborting the export. + private static object ConvertForSqlite(object? value, bool preserveKeyColumn) => value switch { null or DBNull => DBNull.Value, bool b => b ? 1 : 0, @@ -337,7 +402,7 @@ double d when double.IsNaN(d) || double.IsInfinity(d) => DBNull.Value, int[] arr => JsonSerializer.Serialize(arr), float[] arr => JsonSerializer.Serialize(arr), // String GUIDs from PostgreSQL (uuid columns read as string) → UPPERCASE - string s when GuidPattern.IsMatch(s) => s.ToUpperInvariant(), + string s when !preserveKeyColumn && GuidPattern.IsMatch(s) => s.ToUpperInvariant(), _ => value, }; @@ -347,7 +412,10 @@ private void Log(string message) { if (!string.IsNullOrEmpty(message)) { - _logger.LogInformation("{Message}", message); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("{Message}", message); + } } lock (_logLock) @@ -460,7 +528,7 @@ private async Task CleanOrphanedForeignKeysAsync(SqliteConnection conn, Cancella if (totalDeleted > 0) { Log($"[PostExport] Limpieza FK: {totalDeleted} filas huérfanas eliminadas."); - PostgresLog.Warn($"[Export] FK cleanup: {totalDeleted} orphaned rows removed."); + PostgresLog.Info($"[Export] FK cleanup: {totalDeleted} orphaned rows removed."); } } @@ -496,142 +564,30 @@ private static void EnsureEmptyLibraryDb(string sqliteDbPath) page[23] = 32; // leaf payload fraction File.WriteAllBytes(libraryDbPath, page); - PostgresLog.Warn($"[Export] library.db stub creado: {libraryDbPath}"); + PostgresLog.Info($"[Export] library.db stub creado: {libraryDbPath}"); } - // ── Code migration pre-marking ──────────────────────────────────────────── - // - // When Jellyfin restarts in SQLite mode after an export, it runs pending code - // migrations. Legacy routines like RemoveDuplicateExtras look for TypedBaseItems - // (old library.db schema) which no longer exists. We pre-mark all code migrations - // as applied in __EFMigrationsHistory so Jellyfin skips them entirely. - // - // IDs use the format built by CodeMigration.BuildCodeMigrationId(): - // Order.ToString("yyyyMMddHHmmsss") + "_" + Name - // (note: 3 trailing 's' = 3 decimal digits of seconds in custom format) - - private async Task PreMarkCodeMigrationsAsync(SqliteConnection conn, CancellationToken ct) + // Preserve pending code migrations instead of claiming every discovered routine ran. + private async Task CopyCodeMigrationsAsync(NpgsqlConnection pg, SqliteConnection conn, CancellationToken ct) { - Log("[PostExport] Pre-marcando migraciones de código en __EFMigrationsHistory..."); - - // Collect all types from every loaded assembly. - var allTypes = AppDomain.CurrentDomain.GetAssemblies() - .SelectMany(a => - { - try - { - return a.GetTypes(); - } - catch - { - return System.Array.Empty(); - } - }) - .ToList(); - - // The migration routines live in jellyfin.dll (the entry-point assembly). - // If not already loaded, load it explicitly from the server directory. - if (!allTypes.Any(t => t.GetCustomAttributes(false) - .Any(attr => attr.GetType().Name == "JellyfinMigrationAttribute"))) - { - var serverDir = Path.GetDirectoryName( - AppDomain.CurrentDomain.GetAssemblies() - .FirstOrDefault(a => a.GetName().Name == "Jellyfin.Database.Implementations")?.Location - ?? string.Empty) ?? string.Empty; - - foreach (var candidate in new[] { "jellyfin.dll", "Jellyfin.Server.dll" }) - { - var candidatePath = Path.Combine(serverDir, candidate); - if (File.Exists(candidatePath)) - { - try - { - var asm = System.Runtime.Loader.AssemblyLoadContext.Default - .LoadFromAssemblyPath(candidatePath); - try - { - allTypes.AddRange(asm.GetTypes()); - } - catch - { - // ignore type load errors - } - - break; - } - catch - { - // ignore assembly load errors - } - } - } - } - - var migrationIds = allTypes - .Select(t => t.GetCustomAttributes(false) - .FirstOrDefault(attr => attr.GetType().Name == "JellyfinMigrationAttribute")) - .Where(attr => attr is not null) - .Select(attr => - { - // Order can be DateTime or DateTimeOffset depending on Jellyfin version - var orderRaw = attr!.GetType().GetProperty("Order")!.GetValue(attr)!; - var order = orderRaw is DateTimeOffset dto - ? dto - : new DateTimeOffset((DateTime)orderRaw, TimeSpan.Zero); - var name = (string?)attr.GetType().GetProperty("Name")!.GetValue(attr); - return order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + name; - }) - .OrderBy(id => id, StringComparer.Ordinal) - .ToList(); - - if (migrationIds.Count == 0) - { - Log("[PostExport] ADVERTENCIA: No se encontraron migraciones de código. El arranque SQLite puede fallar."); - PostgresLog.Warn("[Export] PreMarkCodeMigrations: No JellyfinMigrationAttribute types found."); - return; - } - - Log($"[PostExport] {migrationIds.Count} migraciones a pre-marcar."); - PostgresLog.Warn($"[Export] Pre-marcando {migrationIds.Count} migraciones de código en __EFMigrationsHistory."); - - // Ensure the history table exists (created by EF Core Migrate earlier) - const string createHistoryTable = - "CREATE TABLE IF NOT EXISTS \"__EFMigrationsHistory\" " + - "(\"MigrationId\" TEXT NOT NULL CONSTRAINT \"PK___EFMigrationsHistory\" PRIMARY KEY, " + - "\"ProductVersion\" TEXT NOT NULL);"; - await ExportHelpers.ExecSqliteAsync(conn, createHistoryTable, ct).ConfigureAwait(false); - - // Get already-applied migrations to avoid duplicate key errors - var existingIds = new HashSet(StringComparer.Ordinal); - using var selectCmd = conn.CreateCommand(); - selectCmd.CommandText = "SELECT \"MigrationId\" FROM \"__EFMigrationsHistory\";"; - using var reader = await selectCmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + using var select = new NpgsqlCommand("SELECT \"MigrationId\", \"ProductVersion\" FROM \"__EFMigrationsHistory\";", pg); + using var reader = await select.ExecuteReaderAsync(ct).ConfigureAwait(false); + var count = 0; while (await reader.ReadAsync(ct).ConfigureAwait(false)) { - existingIds.Add(reader.GetString(0)); - } - - await reader.DisposeAsync().ConfigureAwait(false); - - const string jellyfinVersion = "10.11.10"; - var inserted = 0; - foreach (var id in migrationIds) - { - if (existingIds.Contains(id)) + var id = reader.GetString(0); + if (!MigrationCodeMigrations.IsCodeMigrationId(id)) { continue; } - using var insertCmd = conn.CreateCommand(); - insertCmd.CommandText = - "INSERT OR IGNORE INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES (@id, @ver);"; - insertCmd.Parameters.AddWithValue("@id", id); - insertCmd.Parameters.AddWithValue("@ver", jellyfinVersion); - await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - inserted++; + using var insert = conn.CreateCommand(); + insert.CommandText = "INSERT OR IGNORE INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES (@id, @version);"; + insert.Parameters.AddWithValue("@id", id); + insert.Parameters.AddWithValue("@version", reader.GetString(1)); + count += await insert.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } - Log($"[PostExport] {inserted} migraciones pre-marcadas correctamente."); - PostgresLog.Warn($"[Export] {inserted} migraciones de código pre-marcadas en __EFMigrationsHistory."); + Log($"[PostExport] {count} migraciones aplicadas copiadas desde PostgreSQL."); } } diff --git a/Jellyfin.Database.Providers.Postgres/Services/HealthCheckService.cs b/Jellyfin.Database.Providers.Postgres/Services/HealthCheckService.cs index f6d711c..d802515 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/HealthCheckService.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/HealthCheckService.cs @@ -97,11 +97,14 @@ public async Task RunHealthCheckAsync( $"Hallazgos: {findings.Count} | Schema: {schema}"); } - _logger.LogInformation( - "Health check completed: {Severity} ({Count} findings) in {Ms} ms", - overall, - findings.Count, - sw.ElapsedMilliseconds); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Health check completed: {Severity} ({Count} findings) in {Ms} ms", + overall, + findings.Count, + sw.ElapsedMilliseconds); + } return new HealthCheckResult(overall, findings, DateTime.UtcNow, sw.ElapsedMilliseconds); } @@ -124,7 +127,7 @@ public async Task RepairAsync( var sw = System.Diagnostics.Stopwatch.StartNew(); var items = new List(); - PostgresLog.Warn($"[HealthCheck] INICIO: Auto-repair en schema '{schema}'..."); + PostgresLog.Info($"[HealthCheck] INICIO: Auto-repair en schema '{schema}'..."); // Re-detect across all schemas so we never try to REINDEX an orphan in pg_toast. var invalidIndexes = await FindInvalidIndexesAsync(connectionString, ct).ConfigureAwait(false); @@ -648,8 +651,12 @@ private async Task RepairIndexAsync( var qualified = $"{MaintenanceService.QuoteIdentifier(safeSchema)}.{MaintenanceService.QuoteIdentifier(safeIndex)}"; await ExecuteNonQueryAsync(pg, $"REINDEX INDEX CONCURRENTLY {qualified};", ct).ConfigureAwait(false); - _logger.LogInformation("Reindexed invalid index {Target}", target); - PostgresLog.Warn($"[HealthCheck] REINDEX OK: {target}"); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Reindexed invalid index {Target}", target); + } + + PostgresLog.Info($"[HealthCheck] REINDEX OK: {target}"); return new HealthRepairItem("Reindex", target, true, "Índice reconstruido correctamente."); } catch (Exception ex) @@ -676,8 +683,12 @@ private async Task RepairBloatAsync( var qualified = $"{MaintenanceService.QuoteIdentifier(safeSchema)}.{MaintenanceService.QuoteIdentifier(safeTable)}"; await ExecuteNonQueryAsync(pg, $"VACUUM ANALYZE {qualified};", ct).ConfigureAwait(false); - _logger.LogInformation("VACUUM ANALYZE {Target} (bloat {Pct:F1}%)", target, deadPct); - PostgresLog.Warn($"[HealthCheck] VACUUM OK: {target}"); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("VACUUM ANALYZE {Target} (bloat {Pct:F1}%)", target, deadPct); + } + + PostgresLog.Info($"[HealthCheck] VACUUM OK: {target}"); return new HealthRepairItem( "Vacuum", target, @@ -707,13 +718,17 @@ private async Task RepairSequenceAsync( var sql = $"SELECT setval({MaintenanceService.QuoteIdentifier(safeSequence)}, {issue.MaxValue.ToString(CultureInfo.InvariantCulture)});"; await ExecuteNonQueryAsync(pg, sql, ct).ConfigureAwait(false); - _logger.LogInformation( - "Fixed sequence {Target}: setval({Max:N0}) for {Table}.{Column}", - target, - issue.MaxValue, - issue.Table, - issue.Column); - PostgresLog.Warn($"[HealthCheck] FIX SEQ OK: {target} → {issue.MaxValue}"); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Fixed sequence {Target}: setval({Max:N0}) for {Table}.{Column}", + target, + issue.MaxValue, + issue.Table, + issue.Column); + } + + PostgresLog.Info($"[HealthCheck] FIX SEQ OK: {target} → {issue.MaxValue}"); return new HealthRepairItem( "FixSequence", target, diff --git a/Jellyfin.Database.Providers.Postgres/Services/HomeQueryCacheInterceptor.cs b/Jellyfin.Database.Providers.Postgres/Services/HomeQueryCacheInterceptor.cs index 5984b50..fc0ab18 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/HomeQueryCacheInterceptor.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/HomeQueryCacheInterceptor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data; using System.Data.Common; @@ -28,7 +28,11 @@ namespace Jellyfin.Database.Providers.Postgres.Services; /// public sealed class HomeQueryCacheInterceptor : DbCommandInterceptor, IDisposable { - // Fields first (SA1201: fields before properties) + // Fields first (SA1201); constants before static readonly (SA1203) + // Result sets larger than this are still buffered (see ReaderExecuting) but not retained + // in the cache, so a big query cannot pin a large DataTable in memory. + private const int MaxCacheableRows = 1000; + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30); // Only intercept queries on the tables that Jellyfin fans out on home-page load. @@ -94,9 +98,13 @@ public override async ValueTask> ReaderExecutin if (_cache.TryGetValue(cacheKey, out DataTable? cached) && cached is not null) { - _logger?.LogDebug( - "HomeQueryCache HIT: {SqlPreview}", - command.CommandText[..Math.Min(command.CommandText.Length, 120)]); + if (_logger?.IsEnabled(LogLevel.Debug) == true) + { + _logger?.LogDebug( + "HomeQueryCache HIT: {SqlPreview}", + command.CommandText[..Math.Min(command.CommandText.Length, 120)]); + } + return InterceptionResult.SuppressWithResult(new DataTableReader(cached)); } @@ -105,6 +113,38 @@ public override async ValueTask> ReaderExecutin return await ExecuteAndCacheAsync(command, cacheKey, cancellationToken).ConfigureAwait(false); } + /// + /// + /// Buffering matters for correctness, not only for caching: several Jellyfin routines + /// (for example MigrateRatingLevels) enumerate a synchronously streamed query and + /// then run ExecuteUpdate on the same connection. SQLite tolerates that, Npgsql does + /// not (no MARS) and fails with NpgsqlOperationInProgressException. Materialising the + /// reader here closes the database reader before Jellyfin issues its next command. + /// + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + if (!IsCacheable(command)) + { + return result; + } + + var cacheKey = BuildCacheKey(command); + if (cacheKey is null) + { + return result; + } + + if (_cache.TryGetValue(cacheKey, out DataTable? cached) && cached is not null) + { + return InterceptionResult.SuppressWithResult(new DataTableReader(cached)); + } + + return ExecuteAndCache(command, cacheKey); + } + // ───────────────────────────────────────────────────────────────────────── // Cache eligibility // ───────────────────────────────────────────────────────────────────────── @@ -219,18 +259,17 @@ private async ValueTask> ExecuteAndCacheAsync( } // SizeLimit: approximate entry weight = column count (rough proxy for memory) - _cache.Set(cacheKey, dt, new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = CacheTtl, - Size = dt.Columns.Count, // proxy: more columns → heavier entry - }); + CacheResult(cacheKey, dt); - _logger?.LogDebug( - "HomeQueryCache MISS (cached {Cols} cols x {Rows} rows, key {KeyPreview}…): {Sql}", - dt.Columns.Count, - dt.Rows.Count, - cacheKey[..Math.Min(cacheKey.Length, 8)], - command.CommandText[..Math.Min(command.CommandText.Length, 120)]); + if (_logger?.IsEnabled(LogLevel.Debug) == true) + { + _logger?.LogDebug( + "HomeQueryCache MISS (cached {Cols} cols x {Rows} rows, key {KeyPreview}…): {Sql}", + dt.Columns.Count, + dt.Rows.Count, + cacheKey[..Math.Min(cacheKey.Length, 8)], + command.CommandText[..Math.Min(command.CommandText.Length, 120)]); + } return InterceptionResult.SuppressWithResult(new DataTableReader(dt)); } @@ -243,6 +282,57 @@ private async ValueTask> ExecuteAndCacheAsync( } } + // ───────────────────────────────────────────────────────────────────────── + // Self-execute + cache (synchronous) + // ───────────────────────────────────────────────────────────────────────── + + private InterceptionResult ExecuteAndCache(DbCommand command, string cacheKey) + { + try + { + if (command.Connection!.State != ConnectionState.Open) + { + command.Connection.Open(); + } + + var dt = new DataTable(); + using (var reader = command.ExecuteReader()) + { + dt.Load(reader); + } + + CacheResult(cacheKey, dt); + + return InterceptionResult.SuppressWithResult(new DataTableReader(dt)); + } + catch (Exception ex) + { + // Never break Jellyfin — if buffering fails, let EF Core execute normally. + _logger?.LogWarning(ex, "HomeQueryCache execution failed — falling back to direct DB query."); + return default; + } + } + + /// + /// Stores a buffered result set in the short-lived cache. + /// + /// Cache key derived from SQL and parameters. + /// Materialised result set. + private void CacheResult(string cacheKey, DataTable dt) + { + if (dt.Rows.Count > MaxCacheableRows) + { + // Big result sets are buffered for correctness but not retained. + return; + } + + _cache.Set(cacheKey, dt, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = CacheTtl, + Size = dt.Columns.Count, // proxy: more columns → heavier entry + }); + } + // ───────────────────────────────────────────────────────────────────────── // ORDER BY RANDOM() → TABLESAMPLE rewrite // ───────────────────────────────────────────────────────────────────────── @@ -290,7 +380,10 @@ private async ValueTask> RewriteRandomAndExecut dt.Load(readerObj); } - _logger?.LogDebug("HomeQueryCache RANDOM rewritten with TABLESAMPLE: {Rows} rows", dt.Rows.Count); + if (_logger?.IsEnabled(LogLevel.Debug) == true) + { + _logger?.LogDebug("HomeQueryCache RANDOM rewritten with TABLESAMPLE: {Rows} rows", dt.Rows.Count); + } return InterceptionResult.SuppressWithResult(new DataTableReader(dt)); } @@ -345,7 +438,10 @@ private async ValueTask> RewriteRandomAndExecut public void Purge() { _cache.Compact(1.0); - _logger?.LogDebug("HomeQueryCache purged after external data change."); + if (_logger?.IsEnabled(LogLevel.Debug) == true) + { + _logger?.LogDebug("HomeQueryCache purged after external data change."); + } } // CA2100: sql comes from RewriteRandomSql which only transforms EF Core-generated SQL, never from user input. diff --git a/Jellyfin.Database.Providers.Postgres/Services/Jellyfin121MigrationInterceptor.cs b/Jellyfin.Database.Providers.Postgres/Services/Jellyfin121MigrationInterceptor.cs new file mode 100644 index 0000000..0f18ab6 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Jellyfin121MigrationInterceptor.cs @@ -0,0 +1,99 @@ +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Adapts Jellyfin 12.1's migration SQL to PostgreSQL: translates its SQLite-only JSON cleanup +/// and avoids the pointless row rewrites performed by MigrateRatingLevels. +/// +internal sealed class Jellyfin121MigrationInterceptor : DbCommandInterceptor +{ + internal const string SqliteCleanup = "json_remove(\"Data\", '$.LinkedChildren', '$.ExtraIds', '$.SupportsExternalTransfer')"; + + public override InterceptionResult NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result) + { + Rewrite(command); + return result; + } + + public override ValueTask> NonQueryExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + Rewrite(command); + return ValueTask.FromResult(result); + } + + private static void Rewrite(DbCommand command) + { + if (command.CommandText.Contains(SqliteCleanup, System.StringComparison.Ordinal) + && command.CommandText.Contains("json_valid(\"Data\") = 1", System.StringComparison.Ordinal) + && command.CommandText.TrimStart().StartsWith("UPDATE \"BaseItems\"", System.StringComparison.Ordinal)) + { + // CASE guards the cast itself: PostgreSQL can reorder independent WHERE terms. + command.CommandText = """ + UPDATE "BaseItems" + SET "Data" = CASE WHEN "Data" IS JSON OBJECT + THEN ("Data"::jsonb - ARRAY['LinkedChildren', 'ExtraIds', 'SupportsExternalTransfer'])::text + ELSE "Data" END + WHERE CASE WHEN "Data" IS JSON OBJECT + THEN "Data"::jsonb ?| ARRAY['LinkedChildren', 'ExtraIds', 'SupportsExternalTransfer'] + ELSE false END; + """; + return; + } + + RewriteRatingLevelsNullUpdate(command); + } + + /// + /// Restricted form of the null-rating UPDATE so it only touches rows whose value changes. + /// + /// + /// MigrateRatingLevels sets InheritedParentalRatingValue and + /// InheritedParentalRatingSubValue to NULL for every item without a rating. Setting NULL + /// on a row that is already NULL is a no-op, but PostgreSQL still rewrites the row (heap and + /// TOAST), which over a large library takes longer than the command timeout and aborts the whole + /// migration. Restricting the statement to rows that actually change keeps the result identical + /// with a fraction of the write volume. + /// + /// Command about to be executed. + private static void RewriteRatingLevelsNullUpdate(DbCommand command) + { + var sql = command.CommandText; + if (!sql.Contains("\"OfficialRating\" IS NULL", System.StringComparison.Ordinal) + || !sql.TrimStart().StartsWith("UPDATE \"BaseItems\"", System.StringComparison.Ordinal)) + { + return; + } + + if (sql.Contains("SET \"InheritedParentalRatingValue\" = NULL", System.StringComparison.Ordinal)) + { + if (sql.Contains("\"InheritedParentalRatingValue\" IS NOT NULL", System.StringComparison.Ordinal)) + { + return; // already rewritten + } + + command.CommandText = """ + UPDATE "BaseItems" AS b + SET "InheritedParentalRatingValue" = NULL + WHERE (b."OfficialRating" IS NULL OR b."OfficialRating" = '') + AND b."InheritedParentalRatingValue" IS NOT NULL; + """; + return; + } + + if (sql.Contains("SET \"InheritedParentalRatingSubValue\" = NULL", System.StringComparison.Ordinal) + && !sql.Contains("\"InheritedParentalRatingSubValue\" IS NOT NULL", System.StringComparison.Ordinal)) + { + command.CommandText = """ + UPDATE "BaseItems" AS b + SET "InheritedParentalRatingSubValue" = NULL + WHERE (b."OfficialRating" IS NULL OR b."OfficialRating" = '') + AND b."InheritedParentalRatingSubValue" IS NOT NULL; + """; + } + } +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/MaintenanceBackupService.cs b/Jellyfin.Database.Providers.Postgres/Services/MaintenanceBackupService.cs index f90f65a..07cc772 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/MaintenanceBackupService.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/MaintenanceBackupService.cs @@ -40,6 +40,7 @@ public sealed class MaintenanceBackupService /// Directory where the backup file will be written. /// When , wraps the SQL dump in a ZIP file. /// Optional directory containing pg_dump, psql, etc. Leave null/empty to auto-detect or rely on PATH. + /// Include DROP statements for restoring a pre-migration backup. /// Cancellation token. /// Absolute path to the created backup file. public async Task CreateBackupAsync( @@ -47,6 +48,7 @@ public async Task CreateBackupAsync( string outputDirectory, bool compress, string? pgBinPath, + bool includeCleanCommands = false, CancellationToken ct = default) { var cs = new NpgsqlConnectionStringBuilder(connectionString); @@ -66,9 +68,17 @@ public async Task CreateBackupAsync( // Resolve + validate the executable path; name must be in AllowedPgExecutables (CA3006) var safeExecutable = ResolveExecutable(pgBinPath, "pg_dump"); var args = BuildPgDumpArguments(cs, sqlFilePath); + if (includeCleanCommands) + { + args += " --clean --if-exists"; + } + + if (_logger?.IsEnabled(LogLevel.Information) == true) + { + _logger.LogInformation("Starting PostgreSQL backup with pg_dump to {Path}", sqlFilePath); + } - _logger.LogInformation("Starting PostgreSQL backup with pg_dump to {Path}", sqlFilePath); - PostgresLog.Warn($"[Backup] INICIO: pg_dump → {sqlFilePath}"); + PostgresLog.Info($"[Backup] INICIO: pg_dump → {sqlFilePath}"); var psi = CreateProcessStartInfo(safeExecutable, args); @@ -104,8 +114,12 @@ public async Task CreateBackupAsync( if (!compress) { var sqlSize = MaintenanceService.FormatBytes(GetFileSize(sqlFilePath)); - _logger.LogInformation("PostgreSQL backup completed at {Path} ({Size})", sqlFilePath, sqlSize); - PostgresLog.Warn($"[Backup] COMPLETADO: {sqlFilePath} ({sqlSize})"); + if (_logger?.IsEnabled(LogLevel.Information) == true) + { + _logger.LogInformation("PostgreSQL backup completed at {Path} ({Size})", sqlFilePath, sqlSize); + } + + PostgresLog.Info($"[Backup] COMPLETADO: {sqlFilePath} ({sqlSize})"); return sqlFilePath; } @@ -115,15 +129,19 @@ public async Task CreateBackupAsync( SafeFileDelete(zipPath); } - using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + using (var archive = await ZipFile.OpenAsync(zipPath, ZipArchiveMode.Create, ct).ConfigureAwait(false)) { - archive.CreateEntryFromFile(sqlFilePath, Path.GetFileName(sqlFilePath), CompressionLevel.Optimal); + await archive.CreateEntryFromFileAsync(sqlFilePath, Path.GetFileName(sqlFilePath), CompressionLevel.Optimal, ct).ConfigureAwait(false); } SafeFileDelete(sqlFilePath); var zipSize = MaintenanceService.FormatBytes(GetFileSize(zipPath)); - _logger.LogInformation("PostgreSQL backup completed at {Path} ({Size})", zipPath, zipSize); - PostgresLog.Warn($"[Backup] COMPLETADO (ZIP): {zipPath} ({zipSize})"); + if (_logger?.IsEnabled(LogLevel.Information) == true) + { + _logger.LogInformation("PostgreSQL backup completed at {Path} ({Size})", zipPath, zipSize); + } + + PostgresLog.Info($"[Backup] COMPLETADO (ZIP): {zipPath} ({zipSize})"); return zipPath; } @@ -173,30 +191,7 @@ public async Task RestoreBackupAsync( var ext = Path.GetExtension(backupFilePath); if (string.Equals(ext, ".zip", StringComparison.OrdinalIgnoreCase)) { - tempDir = Path.Combine( - Path.GetTempPath(), - "jellyfin-pg-restore-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); - Directory.CreateDirectory(tempDir); - - using var archive = ZipFile.OpenRead(backupFilePath); - ZipArchiveEntry? sqlEntry = null; - foreach (var entry in archive.Entries) - { - if (entry.FullName.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) - { - sqlEntry = entry; - break; - } - } - - if (sqlEntry is null) - { - throw new InvalidOperationException("ZIP backup does not contain a .sql file."); - } - - // Canonicalize extracted path to prevent zip-slip (CA3003) - sqlFilePath = Path.GetFullPath(Path.Combine(tempDir, Path.GetFileName(sqlEntry.FullName))); - sqlEntry.ExtractToFile(sqlFilePath, overwrite: true); + (tempDir, sqlFilePath) = ExtractSqlFromZip(backupFilePath); } if (!SafeFileExists(sqlFilePath) || !sqlFilePath.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) @@ -213,7 +208,7 @@ public async Task RestoreBackupAsync( backupFilePath, replaceExistingObjects); var sourceSize = MaintenanceService.FormatBytes(GetFileSize(backupFilePath)); - PostgresLog.Warn($"[Restore] INICIO: {backupFilePath} ({sourceSize}) replaceExisting={replaceExistingObjects}"); + PostgresLog.Info($"[Restore] INICIO: {backupFilePath} ({sourceSize}) replaceExisting={replaceExistingObjects}"); var psi = CreateProcessStartInfo(safeRestoreExe, args); @@ -237,7 +232,7 @@ public async Task RestoreBackupAsync( if (process.ExitCode != 0) { var detail = string.IsNullOrWhiteSpace(stdErr) ? stdOut : stdErr; - _logger.LogWarning("psql restore exited with code {Code}: {Detail}", process.ExitCode, detail); + throw new InvalidOperationException($"psql restore failed with exit code {process.ExitCode}: {detail}"); } return sqlFilePath; @@ -250,9 +245,10 @@ public async Task RestoreBackupAsync( { Directory.Delete(tempDir, recursive: true); } - catch (IOException) + catch (IOException ex) { - // best-effort cleanup — ignore + // Best-effort cleanup: keep a trace, it explains a leftover temp directory. + PostgresLog.Debug($"[Backup] No se pudo borrar el directorio temporal {tempDir}: {ex.Message}"); } } } @@ -303,6 +299,31 @@ private static string ResolveExecutable(string? pgBinPath, string executableName return executableName; } + // Extracts the first .sql entry from a ZIP backup into a temp directory. + // Returns (tempDir, sqlFilePath). Caller is responsible for deleting tempDir. + [SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "backupFilePath is canonicalized by ValidateBackupFilePath before reaching here. Extracted path is canonicalized with Path.GetFullPath.")] + private static (string TempDir, string SqlFilePath) ExtractSqlFromZip(string backupFilePath) + { + var tempDir = Path.Combine( + Path.GetTempPath(), + "jellyfin-pg-restore-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); + Directory.CreateDirectory(tempDir); + + using var archive = ZipFile.OpenRead(backupFilePath); + var sqlEntry = archive.Entries.FirstOrDefault(e => e.FullName.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)); + + if (sqlEntry is null) + { + throw new InvalidOperationException("ZIP backup does not contain a .sql file."); + } + + // Canonicalize extracted path to prevent zip-slip (CA3003) + var sqlFilePath = Path.GetFullPath(Path.Combine(tempDir, Path.GetFileName(sqlEntry.FullName))); + sqlEntry.ExtractToFile(sqlFilePath, overwrite: true); + + return (tempDir, sqlFilePath); + } + // Returns the full path if the executable exists in , otherwise null. [SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "dir is either Path.GetFullPath of a user config value or a hard-coded well-known path. executableName is validated against AllowedPgExecutables.")] private static string? FindInDirectory(string dir, string executableName) @@ -330,51 +351,39 @@ private static string ResolveExecutable(string? pgBinPath, string executableName [SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "Paths are constructed from hard-coded OS roots and the allow-listed executable name — no user input flows here.")] private static string? FindInKnownLocations(string executableName) { - // ── Windows: C:\Program Files\PostgreSQL\\bin\ if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform( System.Runtime.InteropServices.OSPlatform.Windows)) { - var programFiles = new[] - { - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), - }; - - foreach (var pf in programFiles) - { - if (string.IsNullOrEmpty(pf)) - { - continue; - } + return FindInWindowsLocations(executableName); + } - var pgRoot = Path.Combine(pf, "PostgreSQL"); - if (!Directory.Exists(pgRoot)) - { - continue; - } + return FindInLinuxLocations(executableName) ?? FindInMacOsLocations(executableName); + } - var versionDirs = Directory.GetDirectories(pgRoot); - System.Array.Sort(versionDirs, StringComparer.OrdinalIgnoreCase); - System.Array.Reverse(versionDirs); // newest first + // ── Windows: C:\Program Files\PostgreSQL\\bin\ + [SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "Paths constructed from hard-coded Program Files roots and allow-listed executable name.")] + private static string? FindInWindowsLocations(string executableName) + { + var programFiles = new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + }; - foreach (var versionDir in versionDirs) - { - var found = FindInDirectory(Path.Combine(versionDir, "bin"), executableName); - if (found is not null) - { - return found; - } - } + foreach (var pf in programFiles) + { + if (string.IsNullOrEmpty(pf)) + { + continue; } - return null; - } + var pgRoot = Path.Combine(pf, "PostgreSQL"); + if (!Directory.Exists(pgRoot)) + { + continue; + } - // ── Linux: /usr/lib/postgresql//bin/ (Debian/Ubuntu packages) - const string linuxPgRoot = "/usr/lib/postgresql"; - if (Directory.Exists(linuxPgRoot)) - { - var versionDirs = Directory.GetDirectories(linuxPgRoot); + var versionDirs = Directory.GetDirectories(pgRoot); System.Array.Sort(versionDirs, StringComparer.OrdinalIgnoreCase); System.Array.Reverse(versionDirs); // newest first @@ -388,21 +397,55 @@ private static string ResolveExecutable(string? pgBinPath, string executableName } } - // ── macOS (Homebrew): /opt/homebrew/opt/postgresql@/bin/ + return null; + } + + // ── Linux: /usr/lib/postgresql//bin/ (Debian/Ubuntu packages) + [SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "Paths constructed from hard-coded Linux roots and allow-listed executable name.")] + private static string? FindInLinuxLocations(string executableName) + { + const string linuxPgRoot = "/usr/lib/postgresql"; + if (!Directory.Exists(linuxPgRoot)) + { + return null; + } + + var versionDirs = Directory.GetDirectories(linuxPgRoot); + System.Array.Sort(versionDirs, StringComparer.OrdinalIgnoreCase); + System.Array.Reverse(versionDirs); // newest first + + foreach (var versionDir in versionDirs) + { + var found = FindInDirectory(Path.Combine(versionDir, "bin"), executableName); + if (found is not null) + { + return found; + } + } + + return null; + } + + // ── macOS (Homebrew): /opt/homebrew/opt/postgresql@/bin/ + [SuppressMessage("Security", "CA3003:Review code for file path injection vulnerabilities", Justification = "Paths constructed from hard-coded Homebrew root and allow-listed executable name.")] + private static string? FindInMacOsLocations(string executableName) + { const string brewRoot = "/opt/homebrew/opt"; - if (Directory.Exists(brewRoot)) + if (!Directory.Exists(brewRoot)) { - var pgDirs = Directory.GetDirectories(brewRoot, "postgresql*"); - System.Array.Sort(pgDirs, StringComparer.OrdinalIgnoreCase); - System.Array.Reverse(pgDirs); + return null; + } - foreach (var pgDir in pgDirs) + var pgDirs = Directory.GetDirectories(brewRoot, "postgresql*"); + System.Array.Sort(pgDirs, StringComparer.OrdinalIgnoreCase); + System.Array.Reverse(pgDirs); + + foreach (var pgDir in pgDirs) + { + var found = FindInDirectory(Path.Combine(pgDir, "bin"), executableName); + if (found is not null) { - var found = FindInDirectory(Path.Combine(pgDir, "bin"), executableName); - if (found is not null) - { - return found; - } + return found; } } @@ -444,6 +487,7 @@ private static string BuildPsqlRestoreArguments( parts.Add(string.Concat("--file=", QuoteArg(sqlFilePath))); parts.Add("--no-password"); + parts.Add("--set=ON_ERROR_STOP=1"); return string.Join(" ", parts); } diff --git a/Jellyfin.Database.Providers.Postgres/Services/MaintenanceService.cs b/Jellyfin.Database.Providers.Postgres/Services/MaintenanceService.cs index e24fe7a..ec579f7 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/MaintenanceService.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/MaintenanceService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -70,7 +70,7 @@ public static async Task GetServerVersionAsync(string connectionString, public async Task VacuumAnalyzeAsync(string connectionString, CancellationToken ct = default) { _logger.LogInformation("Starting VACUUM ANALYZE..."); - PostgresLog.Warn("[Vacuum] INICIO: VACUUM ANALYZE en toda la base de datos..."); + PostgresLog.Info("[Vacuum] INICIO: VACUUM ANALYZE en toda la base de datos..."); var sw = System.Diagnostics.Stopwatch.StartNew(); using var pg = new NpgsqlConnection(connectionString); @@ -80,7 +80,7 @@ public async Task VacuumAnalyzeAsync(string connectionString, CancellationToken sw.Stop(); _logger.LogInformation("VACUUM ANALYZE completed."); - PostgresLog.Warn($"[Vacuum] COMPLETADO en {sw.Elapsed.TotalSeconds:F1}s. Las tablas han sido analizadas y el espacio muerto recuperado."); + PostgresLog.Info($"[Vacuum] COMPLETADO en {sw.Elapsed.TotalSeconds:F1}s. Las tablas han sido analizadas y el espacio muerto recuperado."); } // ── REINDEX ─────────────────────────────────────────────────────────────── @@ -106,7 +106,7 @@ public async Task ReindexAsync(string connectionString, CancellationToken ct = d // dbName comes from SELECT current_database() — server-controlled, not user input. // QuoteIdentifier escapes any internal double-quotes for safety. - PostgresLog.Warn($"[Reindex] INICIO: REINDEX DATABASE CONCURRENTLY {dbName}..."); + PostgresLog.Info($"[Reindex] INICIO: REINDEX DATABASE CONCURRENTLY {dbName}..."); var sw = System.Diagnostics.Stopwatch.StartNew(); using var cmd = CreateReindexCommand(pg, dbName); @@ -114,7 +114,7 @@ public async Task ReindexAsync(string connectionString, CancellationToken ct = d sw.Stop(); _logger.LogInformation("REINDEX DATABASE completed."); - PostgresLog.Warn($"[Reindex] COMPLETADO en {sw.Elapsed.TotalSeconds:F1}s. Todos los índices de '{dbName}' han sido reconstruidos."); + PostgresLog.Info($"[Reindex] COMPLETADO en {sw.Elapsed.TotalSeconds:F1}s. Todos los índices de '{dbName}' han sido reconstruidos."); } // ── Table statistics ────────────────────────────────────────────────────── @@ -333,7 +333,7 @@ public Task CreateBackupAsync( string? pgBinPath, CancellationToken ct = default) { - return new MaintenanceBackupService(_logger).CreateBackupAsync(connectionString, outputDirectory, compress, pgBinPath, ct); + return new MaintenanceBackupService(_logger).CreateBackupAsync(connectionString, outputDirectory, compress, pgBinPath, ct: ct); } /// diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationAssemblyResolver.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationAssemblyResolver.cs new file mode 100644 index 0000000..34db096 --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationAssemblyResolver.cs @@ -0,0 +1,132 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Runtime.Loader; +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Publishes a non-collectible copy of the plugin assembly in the default load context so that +/// EF Core can resolve the migrations assembly by simple name. +/// +/// +/// +/// Jellyfin 12 loads regular plugins into a PluginLoadContext, which is collectible +/// (AssemblyLoadContext(isCollectible: true)). EF Core's migrations-assembly service resolves +/// the configured migrations assembly with Assembly.Load(AssemblyName) against the default +/// context, and the runtime refuses to resolve it from the collectible context: +/// FileNotFoundException first, and +/// NotSupportedException: A non-collectible assembly may not reference a collectible assembly +/// if a resolving handler hands back the collectible instance. +/// +/// +/// Jellyfin solves this for provider plugins by itself: with DatabaseType=PLUGIN_PROVIDER, +/// LoadDatabasePlugin runs Assembly.LoadFrom on the plugin assembly, which loads it +/// into the default context. This helper replicates that behaviour for the code paths that run +/// before PostgreSQL is the active provider (the SQLite to PostgreSQL migration), where no +/// copy exists in the default context yet. +/// +/// +internal static class MigrationAssemblyResolver +{ + private const string SchemaPreparerTypeName = "Jellyfin.Database.Providers.Postgres.Services.MigrationSchemaPreparer"; + private const string ApplySchemaMethodName = "ApplySchemaIfNeededAsync"; + + private static int _registered; + + /// + /// Registers the dependency resolver and loads a non-collectible copy of the plugin assembly + /// once, then returns the plugin assembly simple name. + /// + /// Simple name of the plugin assembly, suitable for the EF Core migrations assembly option. + internal static string EnsureRegistered() + { + var assembly = typeof(MigrationAssemblyResolver).Assembly; + var simpleName = assembly.GetName().Name!; + var location = assembly.Location; + + if (string.IsNullOrEmpty(location) || !File.Exists(location)) + { + // No file on disk to preload (single-file deployment). + return simpleName; + } + + if (Interlocked.Exchange(ref _registered, 1) == 0) + { + var pluginDirectory = Path.GetDirectoryName(location)!; + + // The plugin bundles its own dependencies (Npgsql, Npgsql EF provider) which are not in + // the server directory, so the default context must be told where to find them before + // the plugin copy is loaded there. + AssemblyLoadContext.Default.Resolving += (_, requested) => + { + if (string.IsNullOrEmpty(requested.Name)) + { + return null; + } + + var candidate = Path.Combine(pluginDirectory, requested.Name + ".dll"); + return File.Exists(candidate) + ? AssemblyLoadContext.Default.LoadFromAssemblyPath(candidate) + : null; + }; + + if (!IsLoadedInDefaultContext(simpleName)) + { + AssemblyLoadContext.Default.LoadFromAssemblyPath(location); + } + } + + return simpleName; + } + + /// + /// Creates or upgrades the PostgreSQL schema, running EF Core inside the default load context. + /// + /// PostgreSQL connection string. + /// Target schema name. + /// Callback for progress messages. + /// Cancellation token. + /// A representing the asynchronous operation. + internal static async Task ApplySchemaAsync(string pgConnStr, string schema, Action log, CancellationToken ct) + { + var assemblyName = EnsureRegistered(); + var defaultAssembly = AssemblyLoadContext.Default.Assemblies + .FirstOrDefault(a => string.Equals(a.GetName().Name, assemblyName, StringComparison.OrdinalIgnoreCase)); + + if (defaultAssembly is null || ReferenceEquals(defaultAssembly, typeof(MigrationAssemblyResolver).Assembly)) + { + // Already running inside the default context (Jellyfin 10.11, or the provider path in 12). + await MigrationSchemaPreparer.ApplySchemaIfNeededAsync(pgConnStr, schema, log, ct).ConfigureAwait(false); + return; + } + + // EF Core has to run inside the default context. The plugin context holds its own copy of + // Npgsql.EntityFrameworkCore.PostgreSQL, and handing EF types coming from both copies makes + // it throw InvalidCastException while comparing model annotations. + var preparer = defaultAssembly.GetType(SchemaPreparerTypeName, throwOnError: true)!; + var method = preparer.GetMethod(ApplySchemaMethodName, BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException(preparer.FullName, ApplySchemaMethodName); + + try + { + if (method.Invoke(null, new object?[] { pgConnStr, schema, log, ct }) is Task task) + { + await task.ConfigureAwait(false); + } + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + // Surface the real failure instead of the reflection wrapper. + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + } + } + + private static bool IsLoadedInDefaultContext(string simpleName) + => AssemblyLoadContext.Default.Assemblies + .Any(a => string.Equals(a.GetName().Name, simpleName, StringComparison.OrdinalIgnoreCase)); +} diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationCodeMigrations.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationCodeMigrations.cs index dbb67b9..f20686f 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/MigrationCodeMigrations.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationCodeMigrations.cs @@ -1,101 +1,58 @@ using System; -using System.Globalization; -using System.Linq; -using System.Reflection; using System.Threading.Tasks; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; using Npgsql; namespace Jellyfin.Database.Providers.Postgres.Services; /// -/// Pre-marks Jellyfin code-based migrations in __EFMigrationsHistory -/// so EF Core does not try to re-run them after the data import. +/// Validates the source schema and transfers only code migrations already applied to its data. /// internal static class MigrationCodeMigrations { - private static readonly string[] MigrationIdFormats = { "yyyyMMddHHmmss", "yyyyMMddHHmmsss" }; - - /// - /// Discovers Jellyfin migration types in the running AppDomain and inserts - /// their IDs into __EFMigrationsHistory using ON CONFLICT DO NOTHING. - /// - /// Open PostgreSQL connection. - /// Callback for progress messages. - /// A representing the asynchronous operation. - internal static async Task PreMarkAsync(NpgsqlConnection pg, Action log) + internal static async Task ValidateSourceAsync(SqliteConnection sqlite) { - Type? attrType = null; - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + using var context = new PostgresDesignTimeDbFactory().CreateDbContext(Array.Empty()); + foreach (var table in context.Model.GetRelationalModel().Tables) { - attrType = asm.GetType("Jellyfin.Server.Migrations.JellyfinMigrationAttribute"); - if (attrType is not null) + var columns = await MigrationDiscovery.GetSqliteColumnNamesAsync(sqlite, table.Name).ConfigureAwait(false); + foreach (var column in table.Columns) { - break; + if (!columns.Contains(column.Name, StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"SQLite no tiene {table.Name}.{column.Name}. Actualiza y arranca Jellyfin 12.1 con SQLite antes de importar a PostgreSQL."); + } } } + } - if (attrType is null) - { - log("[CodeMigrations] JellyfinMigrationAttribute no encontrado en el AppDomain."); - return; - } - - var orderProp = attrType.GetProperty("Order"); - var nameProp = attrType.GetProperty("Name"); - if (orderProp is null || nameProp is null) - { - log("[CodeMigrations] Propiedades Order/Name no encontradas."); - return; - } - - const string insertSql = - "INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES (@id, @ver) ON CONFLICT DO NOTHING;"; - + internal static async Task CopyAppliedAsync(SqliteConnection sqlite, NpgsqlConnection pg, Action log) + { + using var select = sqlite.CreateCommand(); + select.CommandText = "SELECT \"MigrationId\", \"ProductVersion\" FROM \"__EFMigrationsHistory\";"; + using var reader = await select.ExecuteReaderAsync().ConfigureAwait(false); var count = 0; - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + while (await reader.ReadAsync().ConfigureAwait(false)) { - Type[] allTypes; - try - { - allTypes = asm.GetTypes(); - } - catch (ReflectionTypeLoadException ex) + var id = reader.GetString(0); + // Jellyfin code IDs use yyyyMMddHHmmsss; provider schema IDs use 14 digits. + if (!IsCodeMigrationId(id)) { - allTypes = ex.Types.Where(t => t is not null).ToArray()!; + continue; } - foreach (var type in allTypes) - { - if (type is null) - { - continue; - } - - var attrs = type.GetCustomAttributes(attrType, false); - if (attrs.Length == 0) - { - continue; - } - - var attr = attrs[0]; - var order = (DateTime)orderProp.GetValue(attr)!; - var migName = (string)nameProp.GetValue(attr)!; - - foreach (var fmt in MigrationIdFormats) - { - var migrationId = order.ToString(fmt, CultureInfo.InvariantCulture) + "_" + migName; - using var cmd = new NpgsqlCommand(insertSql, pg); - cmd.Parameters.AddWithValue("id", migrationId); - cmd.Parameters.AddWithValue("ver", "0.0.0"); - var inserted = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - if (inserted > 0) - { - count++; - } - } - } + using var insert = new NpgsqlCommand( + "INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES (@id, @version) ON CONFLICT DO NOTHING;", pg); + insert.Parameters.AddWithValue("id", id); + insert.Parameters.AddWithValue("version", reader.GetString(1)); + count += await insert.ExecuteNonQueryAsync().ConfigureAwait(false); } - log($"[CodeMigrations] {count} migration(es) pre-marcadas."); + log($"[CodeMigrations] {count} migraciones aplicadas copiadas desde SQLite."); } + + internal static bool IsCodeMigrationId(string id) + => id.Length > 16 && id[15] == '_' && !id.AsSpan(0, 15).ContainsAnyExceptInRange('0', '9'); } diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationEngine.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationEngine.cs index 2ad19dc..e687139 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/MigrationEngine.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationEngine.cs @@ -1,183 +1,265 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Providers.Postgres.Logging; -using MediaBrowser.Common.Configuration; +using Jellyfin.Database.Providers.Postgres.Services.Models; using Microsoft.Data.Sqlite; -using Microsoft.Extensions.Logging; using Npgsql; namespace Jellyfin.Database.Providers.Postgres.Services; /// -/// Orchestrates the SQLite → PostgreSQL migration by calling focused helpers. +/// Orchestrates the SQLite to PostgreSQL migration by calling focused helpers. /// internal static class MigrationEngine { private static readonly HashSet SkipTables = new(StringComparer.OrdinalIgnoreCase) { "__EFMigrationsHistory", "__EFMigrationsLock" }; + /// + /// Runs the full SQLite to PostgreSQL migration pipeline. + /// + /// Migration options (paths, schema, batch size, etc.). + /// Migration service used for progress reporting and logging. + /// Cancellation token. + /// A representing the asynchronous operation. internal static async Task RunAsync( - string sqlitePath, - string postgresConnectionString, - string schema, - int batchSize, - bool truncate, - IApplicationPaths? applicationPaths, + MigrationOptions options, MigrationService svc, CancellationToken ct) { - if (!File.Exists(sqlitePath)) + if (!File.Exists(options.SqlitePath)) { - throw new FileNotFoundException($"SQLite file not found: {sqlitePath}", sqlitePath); + throw new FileNotFoundException($"SQLite file not found: {options.SqlitePath}", options.SqlitePath); } - svc.Log($"Iniciando migración: {sqlitePath} → PostgreSQL (schema={schema}, batch={batchSize})"); + svc.Log($"Iniciando migracion: {options.SqlitePath} -> PostgreSQL (schema={options.Schema}, batch={options.BatchSize})"); - using var sqlite = new SqliteConnection($"Data Source={sqlitePath};Mode=ReadOnly;Cache=Shared"); - using var pg = new NpgsqlConnection(postgresConnectionString); + using var sqlite = new SqliteConnection($"Data Source={options.SqlitePath};Mode=ReadOnly;Cache=Shared"); + var pgConnection = new NpgsqlConnectionStringBuilder(options.PostgresConnectionString) + { + SearchPath = MigrationDiscovery.QuoteIdentifier(options.Schema), + }; + using var pg = new NpgsqlConnection(pgConnection.ConnectionString); await sqlite.OpenAsync(ct).ConfigureAwait(false); + await MigrationCodeMigrations.ValidateSourceAsync(sqlite).ConfigureAwait(false); await pg.OpenAsync(ct).ConfigureAwait(false); - await MigrationSchemaPreparer.ApplySchemaIfNeededAsync(pg, schema, svc.Log).ConfigureAwait(false); - await MigrationSchemaPreparer.EnsureTimestampColumnsAreTimestamptzAsync(pg, schema, svc.Log).ConfigureAwait(false); - await MigrationCodeMigrations.PreMarkAsync(pg, svc.Log).ConfigureAwait(false); + await MigrationAssemblyResolver + .ApplySchemaAsync(options.PostgresConnectionString, options.Schema, svc.Log, ct) + .ConfigureAwait(false); + await MigrationSchemaPreparer.EnsureTimestampColumnsAreTimestamptzAsync(pg, options.Schema, svc.Log).ConfigureAwait(false); + + var tablesToMigrate = await DiscoverTablesAsync(sqlite, pg, options.Schema, svc).ConfigureAwait(false); + + await MigrationFkManager + .SetFkTriggersAsync(pg, options.Schema, tablesToMigrate, enable: false, svc.Log, MigrationNullLogger.Instance) + .ConfigureAwait(false); + + var needNormalizedFix = await PrepareNormalizedUsernameAsync(sqlite, pg, options.Schema, tablesToMigrate, svc).ConfigureAwait(false); + + var errors = new List<(string Table, string Message)>(); + long totalRows = 0; + + try + { + totalRows = await CopyAllTablesAsync(sqlite, pg, options, tablesToMigrate, errors, svc).ConfigureAwait(false); + await ApplyPostCopyFixesAsync(pg, options.Schema, needNormalizedFix, svc).ConfigureAwait(false); + } + finally + { + await MigrationFkManager + .SetFkTriggersAsync(pg, options.Schema, tablesToMigrate, enable: true, svc.Log, MigrationNullLogger.Instance) + .ConfigureAwait(false); + } + + svc.SetPercentComplete(100); + if (errors.Count > 0) + { + throw new InvalidOperationException($"{errors.Count} tabla(s) fallaron: " + string.Join(", ", errors.Select(e => e.Table))); + } + + await MigrationCodeMigrations.CopyAppliedAsync(sqlite, pg, svc.Log).ConfigureAwait(false); + + svc.Log($"Migracion completada. Total de filas copiadas: {totalRows}."); + PostgresLog.Info($"[Migration] Total filas migradas: {totalRows:N0}"); + } + + // Private helpers + + private static async Task> DiscoverTablesAsync( + SqliteConnection sqlite, + NpgsqlConnection pg, + string schema, + MigrationService svc) + { var tables = await MigrationDiscovery.GetSqliteTablesAsync(sqlite).ConfigureAwait(false); var tablesToMigrate = tables.Where(t => !SkipTables.Contains(t)).ToList(); + svc.Log($"Tablas SQLite: {tables.Count} total, {tablesToMigrate.Count} a migrar"); - PostgresLog.Warn($"[Migration] Tablas SQLite: {tables.Count} total, {tablesToMigrate.Count} a migrar"); + PostgresLog.Info($"[Migration] Tablas SQLite: {tables.Count} total, {tablesToMigrate.Count} a migrar"); - tablesToMigrate = await MigrationDiscovery + return await MigrationDiscovery .SortTablesByFkDependencyAsync(pg, schema, tablesToMigrate) .ConfigureAwait(false); + } - await MigrationFkManager - .SetFkTriggersAsync(pg, schema, tablesToMigrate, enable: false, svc.Log, MigrationNullLogger.Instance) - .ConfigureAwait(false); - - var needNormalizedFix = false; - if (tablesToMigrate.Any(t => string.Equals(t, "Users", StringComparison.OrdinalIgnoreCase))) + private static async Task PrepareNormalizedUsernameAsync( + SqliteConnection sqlite, + NpgsqlConnection pg, + string schema, + List tablesToMigrate, + MigrationService svc) + { + if (!tablesToMigrate.Any(t => string.Equals(t, "Users", StringComparison.OrdinalIgnoreCase))) { - var pgColsUsers = await MigrationDiscovery.GetPgColumnsAsync(pg, schema, "Users").ConfigureAwait(false); - var sqliteColsUsers = await MigrationDiscovery.GetSqliteColumnNamesAsync(sqlite, "Users").ConfigureAwait(false); - needNormalizedFix = pgColsUsers.ContainsKey("NormalizedUsername") - && !sqliteColsUsers.Any(c => string.Equals(c, "NormalizedUsername", StringComparison.OrdinalIgnoreCase)); + return false; + } - if (needNormalizedFix) - { - svc.Log("[Users] Permitiendo NULL en NormalizedUsername temporalmente..."); - using var alterCmd = CreateAlterNormalizedUsernameDropNotNullCommand(pg, schema); - await alterCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - } + var pgColsUsers = await MigrationDiscovery.GetPgColumnsAsync(pg, schema, "Users").ConfigureAwait(false); + var sqliteColsUsers = await MigrationDiscovery.GetSqliteColumnNamesAsync(sqlite, "Users").ConfigureAwait(false); + var needFix = pgColsUsers.ContainsKey("NormalizedUsername") + && !sqliteColsUsers.Any(c => string.Equals(c, "NormalizedUsername", StringComparison.OrdinalIgnoreCase)); + + if (needFix) + { + svc.Log("[Users] Permitiendo NULL en NormalizedUsername temporalmente..."); + using var alterCmd = CreateAlterNormalizedUsernameDropNotNullCommand(pg, schema); + await alterCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); } - var errors = new List<(string Table, string Message)>(); + return needFix; + } + + private static async Task CopyAllTablesAsync( + SqliteConnection sqlite, + NpgsqlConnection pg, + MigrationOptions options, + List tablesToMigrate, + List<(string Table, string Message)> errors, + MigrationService svc) + { long totalRows = 0; - try + // Truncate all targets before copying any rows: CASCADE during a later table + // would otherwise erase rows already imported into its dependent tables. + if (options.Truncate) { - for (var i = 0; i < tablesToMigrate.Count; i++) + foreach (var table in tablesToMigrate) { - var table = tablesToMigrate[i]; - svc.SetCurrentTable(table); - svc.SetPercentComplete((int)Math.Round((double)i / tablesToMigrate.Count * 95)); - - var sqliteCols = await MigrationDiscovery.GetSqliteColumnNamesAsync(sqlite, table).ConfigureAwait(false); - if (sqliteCols.Count == 0) - { - svc.Log($"[{table}] sin columnas en SQLite, omitido."); - continue; - } + using var truncCmd = CreateTruncateCommand(pg, options.Schema, table); + await truncCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + } + } - var pgCols = await MigrationDiscovery.GetPgColumnsAsync(pg, schema, table).ConfigureAwait(false); - if (pgCols.Count == 0) - { - svc.Log($"[{table}] no existe en PostgreSQL, omitido."); - continue; - } + for (var i = 0; i < tablesToMigrate.Count; i++) + { + var table = tablesToMigrate[i]; + svc.SetCurrentTable(table); + svc.SetPercentComplete((int)Math.Round((double)i / tablesToMigrate.Count * 95)); - var commonCols = sqliteCols - .Where(sc => pgCols.ContainsKey(sc)) - .Select(sc => new TargetColumn(sc, pgCols[sc].PgType, pgCols[sc].MaxLength)) - .ToList(); + var commonCols = await ResolveCommonColumnsAsync(sqlite, pg, options.Schema, table, svc).ConfigureAwait(false); + if (commonCols is null) + { + continue; + } - if (commonCols.Count == 0) + try + { + if (options.Truncate) { - svc.Log($"[{table}] ninguna columna coincide, omitido."); - continue; + svc.Log($"[{table}] truncando..."); + using var truncCmd = CreateTruncateCommand(pg, options.Schema, table); + await truncCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); } - try - { - if (truncate) - { - svc.Log($"[{table}] truncando..."); - using var truncCmd = CreateTruncateCommand(pg, schema, table); - await truncCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - } - - svc.Log($"[{table}] copiando {commonCols.Count} columna(s)..."); - var rowCount = await MigrationTableCopier - .CopyTableAsync(sqlite, pg, schema, table, commonCols, batchSize, svc.Log) - .ConfigureAwait(false); - - totalRows += rowCount; - svc.AddMigratedRows(rowCount); - svc.Log($"[{table}] OK — {rowCount} filas."); - PostgresLog.Warn($"[Migration] → {table} ({rowCount:N0} filas)"); - } - catch (Exception ex) - { - svc.Log($"[{table}] ERROR: {ex.Message}"); - errors.Add((table, ex.Message)); - PostgresLog.Error($"[Migration] ERROR en tabla {table}: {ex.Message}", ex); - } - } + svc.Log($"[{table}] copiando {commonCols.Count} columna(s)..."); + var rowCount = await MigrationTableCopier + .CopyTableAsync(sqlite, pg, options.Schema, table, commonCols, options.BatchSize, svc.Log) + .ConfigureAwait(false); - if (needNormalizedFix) + totalRows += rowCount; + svc.AddMigratedRows(rowCount); + svc.Log($"[{table}] OK - {rowCount} filas."); + PostgresLog.Info($"[Migration] -> {table} ({rowCount:N0} filas)"); + } + catch (Exception ex) { - svc.Log("[Users] Calculando NormalizedUsername = UPPER(Username)..."); - using var fixCmd = CreateFillNormalizedUsernameCommand(pg, schema); - var updated = await fixCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - svc.Log($"[Users] {updated} filas actualizadas."); - - using var restoreCmd = CreateAlterNormalizedUsernameSetNotNullCommand(pg, schema); - await restoreCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - svc.Log("[Users] Restricción NOT NULL restaurada."); + svc.Log($"[{table}] ERROR: {ex.Message}"); + errors.Add((table, ex.Message)); + PostgresLog.Error($"[Migration] ERROR en tabla {table}: {ex.Message}", ex); } + } - svc.Log("Reseteando secuencias PostgreSQL..."); - await MigrationSchemaPreparer.ResetSequencesAsync(pg, schema).ConfigureAwait(false); - svc.Log("Secuencias reseteadas."); + return totalRows; + } + + private static async Task?> ResolveCommonColumnsAsync( + SqliteConnection sqlite, + NpgsqlConnection pg, + string schema, + string table, + MigrationService svc) + { + var sqliteCols = await MigrationDiscovery.GetSqliteColumnNamesAsync(sqlite, table).ConfigureAwait(false); + if (sqliteCols.Count == 0) + { + svc.Log($"[{table}] sin columnas en SQLite, omitido."); + return null; } - finally + + var pgCols = await MigrationDiscovery.GetPgColumnsAsync(pg, schema, table).ConfigureAwait(false); + if (pgCols.Count == 0) { - await MigrationFkManager - .SetFkTriggersAsync(pg, schema, tablesToMigrate, enable: true, svc.Log, MigrationNullLogger.Instance) - .ConfigureAwait(false); + svc.Log($"[{table}] no existe en PostgreSQL, omitido."); + return null; } - svc.SetPercentComplete(100); + var commonCols = sqliteCols + .Where(sc => pgCols.ContainsKey(sc)) + .Select(sc => new TargetColumn(sc, pgCols[sc].PgType, pgCols[sc].MaxLength)) + .ToList(); - if (errors.Count > 0) + if (commonCols.Count == 0) + { + svc.Log($"[{table}] ninguna columna coincide, omitido."); + return null; + } + + return commonCols; + } + + private static async Task ApplyPostCopyFixesAsync( + NpgsqlConnection pg, + string schema, + bool needNormalizedFix, + MigrationService svc) + { + if (needNormalizedFix) { - svc.Log($"[WARN] {errors.Count} tabla(s) fallaron: " + string.Join(", ", errors.Select(e => e.Table))); + svc.Log("[Users] Calculando NormalizedUsername = UPPER(Username)..."); + using var fixCmd = CreateFillNormalizedUsernameCommand(pg, schema); + var updated = await fixCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + svc.Log($"[Users] {updated} filas actualizadas."); + + using var restoreCmd = CreateAlterNormalizedUsernameSetNotNullCommand(pg, schema); + await restoreCmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + svc.Log("[Users] Restriccion NOT NULL restaurada."); } - svc.Log($"Migración completada. Total de filas copiadas: {totalRows}."); - PostgresLog.Warn($"[Migration] Total filas migradas: {totalRows:N0}"); + svc.Log("Reseteando secuencias PostgreSQL..."); + await MigrationSchemaPreparer.ResetSequencesAsync(pg, schema).ConfigureAwait(false); + svc.Log("Secuencias reseteadas."); } - // ── Command factory methods ───────────────────────────────────────────────────────── - // schema/table come from the migration configuration or from pg_tables/sqlite_master - // (server-controlled catalog data), never from raw HTTP user input. + // Command factory methods + // schema/table come from migration configuration or pg_tables/sqlite_master (server-controlled), never from user input. [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "SQL is built from QuoteIdentifier on schema/table names sourced from server catalog, not user input.")] private static NpgsqlCommand CreateAlterNormalizedUsernameDropNotNullCommand(NpgsqlConnection pg, string schema) diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationSchemaPreparer.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationSchemaPreparer.cs index c7aae6c..1c6a75b 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/MigrationSchemaPreparer.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationSchemaPreparer.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; -using System.Text; using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Locking; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; using Npgsql; namespace Jellyfin.Database.Providers.Postgres.Services; @@ -18,53 +20,47 @@ internal static class MigrationSchemaPreparer // ── Schema auto-creation ────────────────────────────────────────────────── /// - /// Applies the embedded schema SQL if the target schema does not exist yet. + /// Applies pending PostgreSQL migrations, including upgrades of existing schemas. /// - /// Open PostgreSQL connection. + /// + /// Takes a connection string instead of an open connection on purpose: when the plugin runs in + /// a collectible load context (Jellyfin 12) this method is executed through + /// inside the default context, so it + /// has to own its connection. + /// + /// PostgreSQL connection string. /// Target schema name. /// Callback for progress messages. + /// Cancellation token. /// A representing the asynchronous operation. - internal static async Task ApplySchemaIfNeededAsync(NpgsqlConnection pg, string schema, Action log) + internal static async Task ApplySchemaIfNeededAsync(string pgConnStr, string schema, Action log, CancellationToken ct) { - const string checkSql = - "SELECT 1 FROM information_schema.tables WHERE table_schema = @s AND table_name = 'BaseItems' LIMIT 1;"; - - object? exists; - using var checkCmd = new NpgsqlCommand(checkSql, pg); - checkCmd.Parameters.AddWithValue("s", schema); - exists = await checkCmd.ExecuteScalarAsync().ConfigureAwait(false); - - if (exists is not null) - { - log("[Schema] Schema ya existe — omitiendo creación."); - return; - } - - log("[Schema] Creando schema desde SQL embebido..."); - - const string resourceName = "Jellyfin.Database.Providers.Postgres.Resources.schema.sql"; - string schemaSql; - using (var stream = typeof(MigrationSchemaPreparer).Assembly.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException($"Embedded resource not found: {resourceName}")) - using (var reader = new StreamReader(stream, Encoding.UTF8)) + var builder = new NpgsqlConnectionStringBuilder(pgConnStr) { - schemaSql = await reader.ReadToEndAsync().ConfigureAwait(false); - } + SearchPath = MigrationDiscovery.QuoteIdentifier(schema), + }; - // Static separator array (CA1861: avoid constant array allocations per call) - var statements = schemaSql - .Split(MigrationService.SqlStatementSeparators, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim().TrimEnd(';')) - .Where(s => !string.IsNullOrWhiteSpace(s)) - .ToList(); + using var pg = new NpgsqlConnection(builder.ConnectionString); + await pg.OpenAsync(ct).ConfigureAwait(false); - foreach (var statement in statements) + using (var createSchema = CreateSchemaStatementCommand(pg, "CREATE SCHEMA IF NOT EXISTS " + MigrationDiscovery.QuoteIdentifier(schema))) { - using var cmd = CreateSchemaStatementCommand(pg, statement); - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + await createSchema.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } - log($"[Schema] {statements.Count} sentencias aplicadas."); + // Leave the target database ready for Jellyfin's own queries (see SqliteCompatibilityBootstrap). + SqliteCompatibilityBootstrap.Ensure(pg); + + var options = new DbContextOptionsBuilder(); + options.UseNpgsql(pg, npgsql => npgsql.MigrationsAssembly(MigrationAssemblyResolver.EnsureRegistered())); + using var context = new JellyfinDbContext( + options.Options, + NullLogger.Instance, + new PostgresDatabaseProvider(null, null), + new NoLockBehavior( + NullLogger.Instance)); + log("[Schema] Aplicando migraciones PostgreSQL para Jellyfin 12.1..."); + await context.Database.MigrateAsync(ct).ConfigureAwait(false); } // ── Timestamp column normalization ──────────────────────────────────────── @@ -130,8 +126,8 @@ internal static async Task ResetSequencesAsync(NpgsqlConnection pg, string schem // ── Command factory methods (CA2100) ────────────────────────────────────── - // Embedded-resource schema statements are never user input. - [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "statement is sourced from embedded resource (schema.sql), not from user input.")] + // Schema names are quoted as identifiers before reaching this command. + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "Schema DDL uses a fixed statement and an identifier escaped by QuoteIdentifier.")] private static NpgsqlCommand CreateSchemaStatementCommand(NpgsqlConnection pg, string statement) => new NpgsqlCommand(statement, pg); diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationService.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationService.cs index 214e949..b4fa9c8 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/MigrationService.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -19,8 +19,6 @@ public sealed class MigrationService : IDisposable private const int DefaultBatch = 1000; private const int MaxLogLines = 500; - internal static readonly string[] SqlStatementSeparators = { "\r\n\r\n", "\n\n" }; - private readonly ILogger _logger; private readonly SemaphoreSlim _semaphore = new(1, 1); private readonly List _logBuffer = new(MaxLogLines); @@ -100,27 +98,26 @@ public bool StartMigration( _logBuffer.Clear(); } - PostgresLog.Warn("[ENGINE SWITCH] SQLite \u2192 PostgreSQL"); - PostgresLog.Warn($"[ENGINE SWITCH] Origen SQLite: {sqlitePath}"); - PostgresLog.Warn($"[ENGINE SWITCH] Schema PG: {schema} | Batch: {batchSize} | Truncate: {truncate}"); - PostgresLog.Warn($"[Migration] INICIO: {sqlitePath} \u2192 PostgreSQL schema={schema} batch={batchSize} truncate={truncate}"); + PostgresLog.Info("[ENGINE SWITCH] SQLite \u2192 PostgreSQL"); + PostgresLog.Info($"[ENGINE SWITCH] Origen SQLite: {sqlitePath}"); + PostgresLog.Info($"[ENGINE SWITCH] Schema PG: {schema} | Batch: {batchSize} | Truncate: {truncate}"); + PostgresLog.Info($"[Migration] INICIO: {sqlitePath} \u2192 PostgreSQL schema={schema} batch={batchSize} truncate={truncate}"); _ = Task.Run(async () => { try { - await MigrationEngine.RunAsync( - sqlitePath, - postgresConnectionString, - schema, - batchSize, - truncate, - applicationPaths, - this, - CancellationToken.None).ConfigureAwait(false); + var options = new MigrationOptions( + SqlitePath: sqlitePath, + PostgresConnectionString: postgresConnectionString, + Schema: schema, + BatchSize: batchSize, + Truncate: truncate, + ApplicationPaths: applicationPaths); + await MigrationEngine.RunAsync(options, this, CancellationToken.None).ConfigureAwait(false); _isCompleted = true; - PostgresLog.Warn($"[Migration] COMPLETADA: {_migratedRows:N0} filas migradas."); - PostgresLog.Warn("[ENGINE SWITCH] SQLite \u2192 PostgreSQL: COMPLETADO. Activa PostgreSQL desde la UI para finalizar el cambio."); + PostgresLog.Info($"[Migration] COMPLETADA: {_migratedRows:N0} filas migradas."); + PostgresLog.Info("[ENGINE SWITCH] SQLite \u2192 PostgreSQL: COMPLETADO. Activa PostgreSQL desde la UI para finalizar el cambio."); } catch (Exception ex) { @@ -149,7 +146,11 @@ await MigrationEngine.RunAsync( internal void Log(string message) { - _logger.LogInformation("{Message}", message); + if (_logger?.IsEnabled(LogLevel.Information) == true) + { + _logger.LogInformation("{Message}", message); + } + lock (_logLock) { if (_logBuffer.Count >= MaxLogLines) diff --git a/Jellyfin.Database.Providers.Postgres/Services/MigrationTableCopier.cs b/Jellyfin.Database.Providers.Postgres/Services/MigrationTableCopier.cs index c29825d..10ada9f 100644 --- a/Jellyfin.Database.Providers.Postgres/Services/MigrationTableCopier.cs +++ b/Jellyfin.Database.Providers.Postgres/Services/MigrationTableCopier.cs @@ -1,11 +1,11 @@ using System; using System.Collections.Generic; +using System.Data.Common; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Threading.Tasks; using Microsoft.Data.Sqlite; -using Microsoft.Extensions.Logging; using Npgsql; using NpgsqlTypes; @@ -48,23 +48,7 @@ internal static async Task CopyTableAsync( var effectiveBatchSize = Math.Max(1, Math.Min(batchSize, 65_535 / columns.Count)); // Users table special handling: auto-fill NormalizedUsername if missing - var normalizedUsernameIdx = -1; - var usernameIdx = -1; - if (string.Equals(table, "Users", StringComparison.OrdinalIgnoreCase)) - { - for (var i = 0; i < columns.Count; i++) - { - if (string.Equals(columns[i].Name, "NormalizedUsername", StringComparison.OrdinalIgnoreCase)) - { - normalizedUsernameIdx = i; - } - - if (string.Equals(columns[i].Name, "Username", StringComparison.OrdinalIgnoreCase)) - { - usernameIdx = i; - } - } - } + var (normalizedUsernameIdx, usernameIdx) = FindUserColumnIndexes(table, columns); long total = 0; var rows = new List(effectiveBatchSize); @@ -74,19 +58,7 @@ internal static async Task CopyTableAsync( while (await reader.ReadAsync().ConfigureAwait(false)) { - var values = new object?[columns.Count]; - for (var i = 0; i < columns.Count; i++) - { - values[i] = reader.GetValue(i); - } - - if (normalizedUsernameIdx >= 0 && usernameIdx >= 0 - && values[normalizedUsernameIdx] is null or DBNull) - { - values[normalizedUsernameIdx] = - (values[usernameIdx]?.ToString() ?? string.Empty).ToUpperInvariant(); - } - + var values = BuildRowValues(reader, columns, normalizedUsernameIdx, usernameIdx); rows.Add(values); if (rows.Count >= effectiveBatchSize) { @@ -105,6 +77,53 @@ internal static async Task CopyTableAsync( return total; } + private static (int NormalizedUsernameIdx, int UsernameIdx) FindUserColumnIndexes( + string table, IReadOnlyList columns) + { + if (!string.Equals(table, "Users", StringComparison.OrdinalIgnoreCase)) + { + return (-1, -1); + } + + var normalizedIdx = -1; + var usernameIdx = -1; + for (var i = 0; i < columns.Count; i++) + { + if (string.Equals(columns[i].Name, "NormalizedUsername", StringComparison.OrdinalIgnoreCase)) + { + normalizedIdx = i; + } + else if (string.Equals(columns[i].Name, "Username", StringComparison.OrdinalIgnoreCase)) + { + usernameIdx = i; + } + } + + return (normalizedIdx, usernameIdx); + } + + private static object?[] BuildRowValues( + System.Data.Common.DbDataReader reader, + IReadOnlyList columns, + int normalizedUsernameIdx, + int usernameIdx) + { + var values = new object?[columns.Count]; + for (var i = 0; i < columns.Count; i++) + { + values[i] = reader.GetValue(i); + } + + if (normalizedUsernameIdx >= 0 && usernameIdx >= 0 + && values[normalizedUsernameIdx] is null or DBNull) + { + values[normalizedUsernameIdx] = + (values[usernameIdx]?.ToString() ?? string.Empty).ToUpperInvariant(); + } + + return values; + } + // ── Batch insert ────────────────────────────────────────────────────────── private static async Task InsertBatchAsync( @@ -218,35 +237,13 @@ private static NpgsqlParameter BuildParameter( case "bigint": case "integer": case "smallint": - if (long.TryParse(strVal, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longVal)) - { - param.NpgsqlDbType = NpgsqlDbType.Bigint; - param.Value = longVal; - } - else - { - param.NpgsqlDbType = NpgsqlDbType.Bigint; - param.Value = DBNull.Value; - log($" [WARN] {pgTable}.{column.Name}: {pgType} inválido '{strVal}' → NULL"); - } - + BuildIntegerParameter(param, strVal, pgType, pgTable, column.Name, log); break; case "double precision": case "real": case "numeric": - if (double.TryParse(strVal, NumberStyles.Float, CultureInfo.InvariantCulture, out var dblVal)) - { - param.NpgsqlDbType = NpgsqlDbType.Double; - param.Value = dblVal; - } - else - { - param.NpgsqlDbType = NpgsqlDbType.Double; - param.Value = DBNull.Value; - log($" [WARN] {pgTable}.{column.Name}: {pgType} inválido '{strVal}' → NULL"); - } - + BuildFloatParameter(param, strVal, pgType, pgTable, column.Name, log); break; case "timestamp without time zone": @@ -255,19 +252,7 @@ private static NpgsqlParameter BuildParameter( break; case "ARRAY": - var trimmed = strVal.Trim(); - if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) - { - param.NpgsqlDbType = NpgsqlDbType.Unknown; - param.Value = '{' + trimmed[1..^1] + '}'; - } - else - { - param.NpgsqlDbType = NpgsqlDbType.Unknown; - param.Value = DBNull.Value; - log($" [WARN] {pgTable}.{column.Name}: ARRAY inválido '{strVal}' → NULL"); - } - + BuildArrayParameter(param, strVal, pgTable, column.Name, log); break; default: @@ -285,6 +270,55 @@ private static NpgsqlParameter BuildParameter( return param; } + private static void BuildIntegerParameter( + NpgsqlParameter param, string strVal, string pgType, string pgTable, string colName, Action log) + { + if (long.TryParse(strVal, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longVal)) + { + param.NpgsqlDbType = NpgsqlDbType.Bigint; + param.Value = longVal; + } + else + { + param.NpgsqlDbType = NpgsqlDbType.Bigint; + param.Value = DBNull.Value; + log($" [WARN] {pgTable}.{colName}: {pgType} inválido '{strVal}' → NULL"); + } + } + + private static void BuildFloatParameter( + NpgsqlParameter param, string strVal, string pgType, string pgTable, string colName, Action log) + { + if (double.TryParse(strVal, NumberStyles.Float, CultureInfo.InvariantCulture, out var dblVal)) + { + param.NpgsqlDbType = NpgsqlDbType.Double; + param.Value = dblVal; + } + else + { + param.NpgsqlDbType = NpgsqlDbType.Double; + param.Value = DBNull.Value; + log($" [WARN] {pgTable}.{colName}: {pgType} inválido '{strVal}' → NULL"); + } + } + + private static void BuildArrayParameter( + NpgsqlParameter param, string strVal, string pgTable, string colName, Action log) + { + var trimmed = strVal.Trim(); + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) + { + param.NpgsqlDbType = NpgsqlDbType.Unknown; + param.Value = '{' + trimmed[1..^1] + '}'; + } + else + { + param.NpgsqlDbType = NpgsqlDbType.Unknown; + param.Value = DBNull.Value; + log($" [WARN] {pgTable}.{colName}: ARRAY inválido '{strVal}' → NULL"); + } + } + private static void SetTimestampParameter( NpgsqlParameter param, string strVal, string pgType, string pgTable, string colName, Action log) { @@ -333,6 +367,7 @@ private static SqliteCommand CreateSqliteSelectCommand(SqliteConnection sqlite, // pgTable + pgColumnList come from QuoteIdentifier on catalog data, not user input. [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "pgTable/pgColumnList built from QuoteIdentifier on catalog data, not user input.")] + [SuppressMessage("Security", "S2077:Formatting SQL queries is security-sensitive", Justification = "pgTable/pgColumnList are constructed from QuoteIdentifier on server catalog data (pg_tables/sqlite_master), never from user input.")] private static void SetInsertCommandText( NpgsqlCommand cmd, string pgTable, diff --git a/Jellyfin.Database.Providers.Postgres/Services/Models/MigrationOptions.cs b/Jellyfin.Database.Providers.Postgres/Services/Models/MigrationOptions.cs new file mode 100644 index 0000000..978920d --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/Models/MigrationOptions.cs @@ -0,0 +1,22 @@ +using MediaBrowser.Common.Configuration; + +namespace Jellyfin.Database.Providers.Postgres.Services.Models; + +/// +/// Encapsulates all options needed to run a SQLite → PostgreSQL migration. +/// Using a parameter object keeps within +/// the 7-parameter SonarQube limit (S107) and makes call-sites more readable. +/// +/// Absolute path to the source SQLite database file. +/// PostgreSQL connection string for the target database. +/// Target PostgreSQL schema name (e.g. "public"). +/// Number of rows per insert batch. +/// When , truncates each table before copying data. +/// Jellyfin application paths, used to locate the default SQLite file. +public sealed record MigrationOptions( + string SqlitePath, + string PostgresConnectionString, + string Schema, + int BatchSize, + bool Truncate, + IApplicationPaths? ApplicationPaths); diff --git a/Jellyfin.Database.Providers.Postgres/Services/SqliteCompatibilityBootstrap.cs b/Jellyfin.Database.Providers.Postgres/Services/SqliteCompatibilityBootstrap.cs new file mode 100644 index 0000000..97e3d4f --- /dev/null +++ b/Jellyfin.Database.Providers.Postgres/Services/SqliteCompatibilityBootstrap.cs @@ -0,0 +1,160 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Jellyfin.Database.Providers.Postgres.Logging; +using Npgsql; + +namespace Jellyfin.Database.Providers.Postgres.Services; + +/// +/// Creates the small set of database objects that Jellyfin's queries need on PostgreSQL but that +/// SQLite provides out of the box. +/// +/// +/// +/// Jellyfin builds some LINQ queries as min(uuid)/max(uuid) (for example +/// GetLatestChannelItems on the home page, which groups by PresentationUniqueKey). +/// SQLite lets min/max work on any type, PostgreSQL does not define those aggregates +/// for uuid, so the query fails with 42883: no existe la función min(uuid). +/// +/// +/// Creating the aggregates is the provider-side adaptation: it fixes every query that uses them, +/// instead of rewriting Jellyfin's SQL statement by statement. +/// +/// +internal static class SqliteCompatibilityBootstrap +{ + // The helpers compare uuids through LEAST/GREATEST, which use the btree operators PostgreSQL + // already ships for the uuid type. Both are created with CREATE OR REPLACE, and the aggregates + // only when their signature is missing, so running this repeatedly is harmless. + private static readonly string[] Statements = + { + """ + CREATE OR REPLACE FUNCTION uuid_smaller(uuid, uuid) RETURNS uuid + LANGUAGE sql IMMUTABLE PARALLEL SAFE + AS 'SELECT LEAST($1, $2)'; + """, + """ + CREATE OR REPLACE FUNCTION uuid_larger(uuid, uuid) RETURNS uuid + LANGUAGE sql IMMUTABLE PARALLEL SAFE + AS 'SELECT GREATEST($1, $2)'; + """, + """ + DO $do$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_aggregate a + JOIN pg_proc p ON p.oid = a.aggfnoid + WHERE p.proname = 'min' + AND pg_get_function_identity_arguments(p.oid) = 'uuid') + THEN + CREATE AGGREGATE min(uuid) (SFUNC = uuid_smaller, STYPE = uuid, PARALLEL = SAFE); + END IF; + END + $do$; + """, + """ + DO $do$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_aggregate a + JOIN pg_proc p ON p.oid = a.aggfnoid + WHERE p.proname = 'max' + AND pg_get_function_identity_arguments(p.oid) = 'uuid') + THEN + CREATE AGGREGATE max(uuid) (SFUNC = uuid_larger, STYPE = uuid, PARALLEL = SAFE); + END IF; + END + $do$; + """, + }; + + // Remembers the last database that was prepared. Keyed per database instead of per process so a + // server pointed at another database also gets the objects, and so repeated Initialise calls + // (EF Core may call them more than once) stay cheap. + private static readonly object EnsureLock = new(); + + private static string? _ensuredFor; + + /// + /// Creates the compatibility objects once per database, using an open connection. + /// + /// Open PostgreSQL connection (with the target schema in the search path). + internal static void Ensure(NpgsqlConnection conn) + { + ArgumentNullException.ThrowIfNull(conn); + + if (!ShouldEnsure(conn.Host, conn.Port, conn.Database)) + { + return; + } + + Create(conn); + } + + /// + /// Creates the compatibility objects using its own connection. Safe to call repeatedly. + /// + /// PostgreSQL connection string. + internal static void Ensure(string connectionString) + { + var builder = new NpgsqlConnectionStringBuilder(connectionString); + if (!ShouldEnsure(builder.Host, builder.Port, builder.Database)) + { + return; + } + + try + { + using var conn = new NpgsqlConnection(connectionString); + conn.Open(); + Create(conn); + } + catch (Exception ex) + { + // This runs during provider initialisation: a failure here must never stop Jellyfin + // from starting, it only means the compatibility aggregates are missing. + PostgresLog.Error("[Compatibility] No se pudo conectar para crear los agregados min/max(uuid)", ex); + } + } + + private static bool ShouldEnsure(string? host, int port, string? database) + { + var target = string.Create(CultureInfo.InvariantCulture, $"{host}:{port}/{database}"); + lock (EnsureLock) + { + if (string.Equals(_ensuredFor, target, StringComparison.Ordinal)) + { + return false; + } + + _ensuredFor = target; + return true; + } + } + + private static void Create(NpgsqlConnection conn) + { + try + { + foreach (var statement in Statements) + { + using var cmd = CreateCommand(conn, statement); + cmd.ExecuteNonQuery(); + } + + PostgresLog.Info("[Compatibility] Agregados min/max sobre uuid disponibles (compatibilidad SQLite)."); + } + catch (Exception ex) + { + PostgresLog.Error("[Compatibility] No se pudieron crear los agregados min/max(uuid)", ex); + } + } + + // Statements are hardcoded DDL (no user input). + [SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "Hardcoded DDL statements, no user input.")] + private static NpgsqlCommand CreateCommand(NpgsqlConnection conn, string statement) + => new NpgsqlCommand(statement, conn); +} From c1d3ee7903813072df603450ae59a5e15ee31e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 02:07:22 -0700 Subject: [PATCH 04/15] docs: actualizar documentacion, manifiesto, resumen de verificacion y configuracion Sincroniza los READMEs y el manifiesto con la version 3.0.0.0, e incluye el resumen ejecutivo de verificacion generado por la propia suite (docs/VERIFICACION-RESUMEN.md) y el global.json del SDK. --- .github/workflows/build.yaml | 19 ++++- .github/workflows/release.yml | 4 +- .github/workflows/scan-codeql.yaml | 5 ++ .vscode/settings.json | 6 +- Directory.Build.props | 6 +- README.en.md | 5 +- README.md | 5 +- build.yaml | 6 +- docs/JELLYFIN-12.1.md | 87 +++++++++++++++++++++++ docs/VERIFICACION-RESUMEN.md | 107 +++++++++++++++++++++++++++++ global.json | 6 ++ 11 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 docs/JELLYFIN-12.1.md create mode 100644 docs/VERIFICACION-RESUMEN.md create mode 100644 global.json diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0c2d779..a18ce35 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -18,6 +18,18 @@ jobs: build: name: Build runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - name: Checkout uses: actions/checkout@v4 @@ -25,7 +37,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Restore run: dotnet restore Jellyfin.Database.Providers.Postgres.sln @@ -33,6 +45,11 @@ jobs: - name: Build (Release) run: dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release --no-restore + - name: Compatibility and migration checks + env: + POSTGRES_TEST_CONNECTION: Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres + run: dotnet run --project tests/CompatibilityChecks -c Release --no-build + - name: Publish run: dotnet publish Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj -c Release --no-restore -o artifacts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f2ac1c..0fa236f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Normalizar versión (4 partes) id: version @@ -87,7 +87,7 @@ jobs: TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") CHANGELOG="${{ github.event.inputs.changelog }}" jq --arg v "$VERSION" --arg url "$URL" --arg sum "$CHECKSUM" --arg ts "$TS" --arg changelog "$CHANGELOG" \ - '.[0].versions = (([.[0].versions[] | select(.version != $v)]) + [{version: $v, changelog: $changelog, targetAbi: "10.11.0.0", sourceUrl: $url, checksum: $sum, timestamp: $ts}])' \ + '.[0].versions = (([.[0].versions[] | select(.version != $v)]) + [{version: $v, changelog: $changelog, targetAbi: "12.1.0.0", sourceUrl: $url, checksum: $sum, timestamp: $ts}])' \ manifest.json > manifest.tmp.json && mv manifest.tmp.json manifest.json cp manifest.json publish/manifest.json diff --git a/.github/workflows/scan-codeql.yaml b/.github/workflows/scan-codeql.yaml index 05216d6..bf95b0a 100644 --- a/.github/workflows/scan-codeql.yaml +++ b/.github/workflows/scan-codeql.yaml @@ -22,6 +22,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: diff --git a/.vscode/settings.json b/.vscode/settings.json index 269e0b3..568a055 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,9 @@ // jellyfinDataDir: directorio de datos de Jellyfin (configs, plugins, metadata) "jellyfinDataDir": "${env:LOCALAPPDATA}/jellyfin", // Nombre del plugin (debe coincidir con el AssemblyName) - "pluginName": "Jellyfin.Database.Providers.Postgres" + "pluginName": "Jellyfin.Database.Providers.Postgres", + "sonarlint.connectedMode.project": { + "connectionId": "SAETICS", + "projectKey": "BORNIOS_Jellyfin-Database-Providers-Postgres" + }, } diff --git a/Directory.Build.props b/Directory.Build.props index fe2323d..948004c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 2.0.0.0 - 2.0.0.0 - 2.0.0.0 + 3.0.0.0 + 3.0.0.0 + 3.0.0.0 diff --git a/README.en.md b/README.en.md index 9be76c8..09d83dc 100644 --- a/README.en.md +++ b/README.en.md @@ -16,7 +16,7 @@ automatic health check, scheduled maintenance and optional JellyTrend integratio [![Last Commit](https://img.shields.io/github/last-commit/BORNIOS/Jellyfin-Database-Providers-Postgres?style=flat-square&color=00A4DC)](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/commits/main) [![CI Build](https://img.shields.io/github/actions/workflow/status/BORNIOS/Jellyfin-Database-Providers-Postgres/build.yaml?style=flat-square&color=00A4DC&label=CI)](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/actions) -[![Jellyfin](https://img.shields.io/badge/Jellyfin-10.11.x-00A4DC?style=flat-square&logo=jellyfin&logoColor=white)](https://jellyfin.org) +[![Jellyfin](https://img.shields.io/badge/Jellyfin-12.1.x-00A4DC?style=flat-square&logo=jellyfin&logoColor=white)](https://jellyfin.org) [![Release](https://img.shields.io/github/v/release/BORNIOS/Jellyfin-Database-Providers-Postgres?style=flat-square&color=00A4DC)](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/latest) [![Downloads](https://img.shields.io/github/downloads/BORNIOS/Jellyfin-Database-Providers-Postgres/total?style=flat-square&color=00A4DC&label=downloads)](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases) [![Discord](https://img.shields.io/badge/Discord-Jellyfin_Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.jellyfin.org) @@ -24,6 +24,9 @@ automatic health check, scheduled maintenance and optional JellyTrend integratio +> **v3 / Jellyfin 12.1:** requires .NET 10 and PostgreSQL 16+. Read the [upgrade and test guide (Spanish)](docs/JELLYFIN-12.1.md) before replacing the provider. + + --- ## ✨ Features diff --git a/README.md b/README.md index de8b553..614531e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ health check automático, mantenimiento programado e integración opcional con J [![Last Commit](https://img.shields.io/github/last-commit/BORNIOS/Jellyfin-Database-Providers-Postgres?style=flat-square&color=00A4DC&label=último%20commit)](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/commits/main) [![CI Build](https://img.shields.io/github/actions/workflow/status/BORNIOS/Jellyfin-Database-Providers-Postgres/build.yaml?style=flat-square&color=00A4DC&label=CI)](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/actions) -[![Jellyfin](https://img.shields.io/badge/Jellyfin-10.11.x-00A4DC?style=flat-square&logo=jellyfin&logoColor=white)](https://jellyfin.org) +[![Jellyfin](https://img.shields.io/badge/Jellyfin-12.1.x-00A4DC?style=flat-square&logo=jellyfin&logoColor=white)](https://jellyfin.org) [![Release](https://img.shields.io/github/v/release/BORNIOS/Jellyfin-Database-Providers-Postgres?style=flat-square&color=00A4DC)](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/latest) [![Downloads](https://img.shields.io/github/downloads/BORNIOS/Jellyfin-Database-Providers-Postgres/total?style=flat-square&color=00A4DC&label=descargas)](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases) [![Discord](https://img.shields.io/badge/Discord-Comunidad_Jellyfin-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.jellyfin.org) @@ -24,6 +24,9 @@ health check automático, mantenimiento programado e integración opcional con J +> **v3 / Jellyfin 12.1:** requiere .NET 10 y PostgreSQL 16+. Consulta la [guía de actualización y pruebas](docs/JELLYFIN-12.1.md) antes de sustituir el proveedor. + + --- ## ✨ Características diff --git a/build.yaml b/build.yaml index 6eca1c9..5fead5c 100644 --- a/build.yaml +++ b/build.yaml @@ -1,9 +1,9 @@ --- name: "PostgreSQL Database Provider" guid: "a2b5f3e8-4c1d-4f7a-9e6b-8d0c2f1a3b5e" -version: "1.1.0.0" -targetAbi: "10.11.10.0" -framework: "net9.0" +version: "3.0.0.0" +targetAbi: "12.1.0.0" +framework: "net10.0" overview: "Use PostgreSQL as Jellyfin's database backend" description: > Provides PostgreSQL as the Jellyfin database backend via EF Core and Npgsql, diff --git a/docs/JELLYFIN-12.1.md b/docs/JELLYFIN-12.1.md new file mode 100644 index 0000000..fb6d990 --- /dev/null +++ b/docs/JELLYFIN-12.1.md @@ -0,0 +1,87 @@ +# Jellyfin 12.1 / .NET 10 + +## Compatibilidad + +Esta rama produce el proveedor **3.0.0.0**, exclusivo para Jellyfin **12.1.x**. +Requiere .NET SDK 10 para compilar, PostgreSQL **16 o posterior** y herramientas +`pg_dump` / `psql` compatibles con el servidor para los respaldos de actualización. +La limpieza JSON de Jellyfin 12.1 se adapta al predicado `IS JSON` de PostgreSQL 16+. + +| Dependencia | Versión | +| --- | --- | +| Jellyfin.Controller / Common / Database.Implementations | 12.1.0 | +| EF Core / Microsoft.Data.Sqlite | 10.0.11 | +| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.2 | +| Framework / ABI | net10.0 / 12.1.0.0 | + +No instalar este paquete sobre Jellyfin 10.11. El catálogo conserva los releases +anteriores y sus checksums; el workflow agrega el nuevo release con ABI 12.1.0.0. + +## Actualizar + +1. Conservar un respaldo completo de los datos y configuración de Jellyfin y un + `pg_dump` de PostgreSQL antes de actualizar el servidor/proveedor. +2. Sustituir las tres DLL del plugin juntas: `Jellyfin.Database.Providers.Postgres.dll`, + `Npgsql.dll` y `Npgsql.EntityFrameworkCore.PostgreSQL.dll`. No copiar DLL de + Jellyfin, EF Core o ASP.NET desde la salida de compilación al directorio de plugins. +3. Para una base PostgreSQL existente, Jellyfin aplica la migración incremental + `Jellyfin121` al arrancar. Los respaldos solicitados por las migraciones de código + se guardan en `{DataPath}/postgres-migration-backups`. Configurar `PgBinPath` o + incluir las herramientas PostgreSQL en PATH antes del arranque. +4. Para importar SQLite, actualizar y arrancar primero esa base con Jellyfin 12.1. + El importador rechaza tablas/columnas antiguas antes de escribir en PostgreSQL. + No sustituye las migraciones de datos que debe ejecutar el servidor. +5. Revisar inicio de sesión, bibliotecas, listas de reproducción, progreso de + reproducción y logs después del primer arranque real. + +La migración conserva el identificador InitialCreate existente, convierte OwnerId y +PrimaryVersionId a UUID, incorpora LinkedChildren y los índices actuales, elimina +permisos/preferencias huérfanos y consolida ItemValues duplicados preservando sus +asociaciones. No confunde ExtraIds con OriginalLanguage. Las importaciones y +exportaciones copian únicamente migraciones de código realmente aplicadas; las +pendientes siguen pendientes. + +La opción de truncado sigue reemplazando los datos de destino. Todas las tablas se +truncan antes de copiar la primera fila para evitar pérdidas por cascadas posteriores. +Una importación con errores de tabla se reporta como fallida. + +Para volver de versión, restaurar el respaldo completo correspondiente. La migración +inversa de esquema no puede recuperar columnas eliminadas ni listas duplicadas que +la versión anterior no representaba. + +## Compilar y comprobar + +```powershell +dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release +dotnet run --project tests/CompatibilityChecks -c Release +``` + +Las comprobaciones sin variables de entorno no abren conexiones PostgreSQL. +Para integración, proporcionar una instancia **exclusiva de pruebas** con permiso +para crear bases; el runner crea bases nuevas con prefijo `jellyfin_provider_test_`: + +```powershell +$env:POSTGRES_TEST_CONNECTION = 'Host=localhost;Port=55439;Database=postgres;Username=postgres' +$env:POSTGRES_TEST_BACKUPS = '1' # Requiere pg_dump y psql en PATH +dotnet run --project tests/CompatibilityChecks -c Release +``` + +Las pruebas cubren esquema inicial, actualización con datos, UUID, duplicados y +asociaciones, permisos huérfanos, limpieza JSON síncrona/asíncrona, JSON malformado, +importación/reimportación en public y schema personalizado, exportación SQLite, +historial aplicado, rechazo de SQLite antiguo y callbacks de respaldo/restauración. +Los fixtures PostgreSQL y SQLite se conservan para diagnóstico. No usar una +instancia de producción para estas pruebas. + +`Resources/schema.sql` es una referencia generada; el importador utiliza las +migraciones EF, no divide ni ejecuta manualmente ese archivo. + +Para regenerar SQL/migraciones con dotnet-ef 10.0.11, incluir las dependencias del +servidor solo durante el diseño: + +```powershell +dotnet build Jellyfin.Database.Providers.Postgres -c Release -p:IncludeJellyfinRuntime=true +dotnet ef migrations script --project Jellyfin.Database.Providers.Postgres --configuration Release --no-build -o Jellyfin.Database.Providers.Postgres/Resources/schema.sql +``` + +Volver a compilar/publicar sin `IncludeJellyfinRuntime` para distribuir el plugin. diff --git a/docs/VERIFICACION-RESUMEN.md b/docs/VERIFICACION-RESUMEN.md new file mode 100644 index 0000000..074cd79 --- /dev/null +++ b/docs/VERIFICACION-RESUMEN.md @@ -0,0 +1,107 @@ +# Resumen ejecutivo de verificación + +> Documento generado automáticamente por `tools/Generar-ResumenEjecutivo.ps1` a partir del resultado +> real de la suite de pruebas. No se edita a mano. + +| Dato | Valor | +| --- | --- | +| Fecha de ejecución | 2026-09-15 20:27:21 -07:00 | +| Versión del plugin | 3.0.0.0 | +| Jellyfin validado | 12.1.0 | +| Base de datos de pruebas | Jellyfin | +| Entorno | Microsoft Windows 10.0.26200 · SDK 10.0.401 | + +## Resultado global + +| Verificaciones | Correctas | Con error | Omitidas | +| --- | --- | --- | --- | +| 57 | 57 | 0 | 0 | + +## Resultado por área + +| Área | Verificaciones | Resultado | +| --- | --- | --- | +| DatabaseConfigurationTests | 2 | ✅ 2/2 | +| LoggingTests | 3 | ✅ 3/3 | +| PostgresIntegrationTests | 11 | ✅ 11/11 | +| ProviderContractTests | 4 | ✅ 4/4 | +| QueryConsoleTests | 26 | ✅ 26/26 | +| SchemaAuditTests | 2 | ✅ 2/2 | +| SqlRewriteTests | 9 | ✅ 9/9 | + +## Detalle + +| Verificación | Resultado | +| --- | --- | +| DatabaseConfigurationTests.EngineSwitchIsDetectedFromDatabaseXml | ✅ | +| DatabaseConfigurationTests.WrittenDatabaseXmlRoundTripsThroughJellyfinOptions | ✅ | +| LoggingTests.FailuresCarryLevelMessageAndStackTrace | ✅ | +| LoggingTests.LogFileLivesNextToTheOtherPluginLogs | ✅ | +| LoggingTests.PostgresFailuresCarryTheServerDetail | ✅ | +| PostgresIntegrationTests.CompatibilityAggregatesExistAfterSchemaPreparation | ✅ | +| PostgresIntegrationTests.ExportProducesAUsableSqliteDatabase | ✅ | +| PostgresIntegrationTests.InitialSchemaAndPopulatedUpgradeWork | ✅ | +| PostgresIntegrationTests.JsonCleanupWorksInSyncAndAsyncPaths | ✅ | +| PostgresIntegrationTests.OldSqliteSchemaIsRejectedBeforeWriting | ✅ | +| PostgresIntegrationTests.PurgeDatabaseAcceptsEveryTableNameFormatTheServerPasses(tableName: "\"public\".\"PurgeProbe\"") | ✅ | +| PostgresIntegrationTests.PurgeDatabaseAcceptsEveryTableNameFormatTheServerPasses(tableName: "public.\"PurgeProbe\"") | ✅ | +| PostgresIntegrationTests.PurgeDatabaseAcceptsEveryTableNameFormatTheServerPasses(tableName: "public.PurgeProbe") | ✅ | +| PostgresIntegrationTests.PurgeDatabaseAcceptsEveryTableNameFormatTheServerPasses(tableName: "PurgeProbe") | ✅ | +| PostgresIntegrationTests.SqlConsoleReturnsRowsAndRejectsWrites | ✅ | +| PostgresIntegrationTests.SqliteDatabaseImportsIntoPublicAndCustomSchema | ✅ | +| ProviderContractTests.DeclaredJellyfinVersionMatchesTheReferencedSdk | ✅ | +| ProviderContractTests.EntryPointsMatchWhatJellyfinInstantiates | ✅ | +| ProviderContractTests.JellyfinDiscoversExactlyOneProvider | ✅ | +| ProviderContractTests.NoInterfaceMemberIsLeftUnimplemented | ✅ | +| QueryConsoleTests.KeywordsHiddenInLiteralsAreNotRejected | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: " ") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "-- despista\nUPDATE \"BaseItems\" SET \"Name\" = '"···) | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "/* despista */ DELETE FROM \"UserData\"") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "ALTER TABLE \"BaseItems\" ADD COLUMN x int") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "CREATE INDEX ix ON \"BaseItems\" (\"Name\")") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "DELETE FROM \"UserData\"") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "DROP TABLE \"BaseItems\"") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "INSERT INTO \"UserData\" VALUES (1)") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "SELECT * INTO copia FROM \"BaseItems\"") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "SELECT 1; DROP TABLE \"BaseItems\"") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "SET search_path = public") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "TRUNCATE \"UserData\"") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "UPDATE \"BaseItems\" SET \"Name\" = 'x'") | ✅ | +| QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "VACUUM") | ✅ | +| QueryConsoleTests.ReadOnlyStatementsAreAccepted(sql: "/* plan primero */ EXPLAIN SELECT 1") | ✅ | +| QueryConsoleTests.ReadOnlyStatementsAreAccepted(sql: "SELECT 'UPDATE' AS palabra, \"SET\" FROM (VALUES ("···) | ✅ | +| QueryConsoleTests.ReadOnlyStatementsAreAccepted(sql: "select \"Id\" from \"BaseItems\" limit 5;") | ✅ | +| QueryConsoleTests.ReadOnlyStatementsAreAccepted(sql: "SELECT 1") | ✅ | +| QueryConsoleTests.ReadOnlyStatementsAreAccepted(sql: "SELECT COUNT(*) FROM \"UserData\" -- comentario fi"···) | ✅ | +| QueryConsoleTests.ReadOnlyStatementsAreAccepted(sql: "SHOW search_path") | ✅ | +| QueryConsoleTests.ReadOnlyStatementsAreAccepted(sql: "TABLE \"BaseItems\"") | ✅ | +| QueryConsoleTests.ReadOnlyStatementsAreAccepted(sql: "VALUES (1), (2)") | ✅ | +| QueryConsoleTests.ReadOnlyStatementsAreAccepted(sql: "WITH valores AS (SELECT 1 AS n) SELECT n FROM valo"···) | ✅ | +| QueryConsoleTests.TheStatementIsReturnedUnchangedExceptForComments | ✅ | +| SchemaAuditTests.EveryTableAndQualifiedColumnUsedByRawSqlExistsInTheModel | ✅ | +| SchemaAuditTests.SnapshotMatchesTheJellyfinModel | ✅ | +| SqlRewriteTests.RatingLevelsUpdateOnlyTouchesRowsThatChange(column: "InheritedParentalRatingSubValue") | ✅ | +| SqlRewriteTests.RatingLevelsUpdateOnlyTouchesRowsThatChange(column: "InheritedParentalRatingValue") | ✅ | +| SqlRewriteTests.RewritingIsIdempotent(sql: "UPDATE \"BaseItems\" SET \"Data\" = json_remove(\""···) | ✅ | +| SqlRewriteTests.RewritingIsIdempotent(sql: "UPDATE \"BaseItems\" SET \"InheritedParentalRating"···) | ✅ | +| SqlRewriteTests.RewritingIsIdempotent(sql: "UPDATE \"BaseItems\" SET \"InheritedParentalRating"···) | ✅ | +| SqlRewriteTests.SqliteJsonCleanupIsTranslatedToPostgres | ✅ | +| SqlRewriteTests.UnrelatedStatementsAreLeftUntouched(sql: "DELETE FROM \"LinkedChildren\" WHERE \"ParentId\" "···) | ✅ | +| SqlRewriteTests.UnrelatedStatementsAreLeftUntouched(sql: "UPDATE \"BaseItems\" SET \"Name\" = 'x' WHERE \"Id"···) | ✅ | +| SqlRewriteTests.UnrelatedStatementsAreLeftUntouched(sql: "UPDATE \"UserData\" SET \"Played\" = true WHERE \""···) | ✅ | + +## Cómo reproducirlo + +```bash +# Sólo verificaciones sin base de datos +dotnet test tests/Jellyfin.Database.Providers.Postgres.Tests + +# Verificaciones completas, incluida la integración con PostgreSQL +$env:POSTGRES_TEST_CONNECTION = "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=***" +./tools/Generar-ResumenEjecutivo.ps1 +``` + +Las pruebas de integración crean su propia base de datos desechable y la eliminan al terminar: +la cadena de conexión indicada se usa únicamente para `CREATE DATABASE` / `DROP DATABASE`. + diff --git a/global.json b/global.json new file mode 100644 index 0000000..f72210c --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} \ No newline at end of file From 87fea14bf988df127b6c918e3e5d48b22b59c75d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 02:07:40 -0700 Subject: [PATCH 05/15] chore: ignorar los resultados de la suite de pruebas tests/TestResults y los archivos .trx son artefactos generados por dotnet test. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 68856f0..5efaaaf 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,10 @@ project.lock.json project.fragment.lock.json artifacts/ +# Test results (artefactos de la suite de verificacion) +tests/TestResults/ +*.trx + # Publish output publish/ publish_out/ From 48ccf7d954f8ec504e89d6acdf00896a7dcd8004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 02:12:13 -0700 Subject: [PATCH 06/15] feat(ui): coherencia visual del panel y verificacion automatica de su integridad La pestana activa, el foco y las tablas usan ahora el acento del panel y un unico estilo de tabla con cabecera fija. Se anaden 4 pruebas que revisan el HTML del panel como parte de la suite: ids usados por el script, una pestana por seccion, los dos diccionarios con las mismas claves (y todas las usadas traducidas) y que el recurso embebido sea el que el plugin anuncia. Esas pruebas destaparon 8 claves sin traducir que se muestran ahora en espanol. --- .../Web/configurationPage.html | 66 +++++++++++++- docs/VERIFICACION-RESUMEN.md | 9 +- .../WebPageTests.cs | 85 +++++++++++++++++++ 3 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 tests/Jellyfin.Database.Providers.Postgres.Tests/WebPageTests.cs diff --git a/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html b/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html index 1b9e8e2..db31f50 100644 --- a/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html +++ b/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html @@ -108,6 +108,52 @@ } } + /* The script toggles Jellyfin's button-submit/button-default classes; giving them the panel + accent makes the section the user is on unmistakable. */ + #PostgresProviderConfigPage .pg-tab-btn.button-submit { + background: linear-gradient(135deg, #3a6dd8, #5a8ff0) !important; + border-color: rgba(140, 190, 255, 0.55); + color: #fff; + } + + #PostgresProviderConfigPage .pg-tab-btn:hover { + background: rgba(38, 54, 92, 0.9); + } + + /* One table style for every section: sticky header, subtle separators and readable rows. */ + #PostgresProviderConfigPage .pg-card table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + } + + #PostgresProviderConfigPage .pg-card table th { + position: sticky; + top: 0; + text-align: left; + padding: 8px 10px; + background: rgba(13, 20, 38, 0.94); + border-bottom: 1px solid var(--pg-border); + color: #cfe0ff; + font-weight: 600; + white-space: nowrap; + } + + #PostgresProviderConfigPage .pg-card table td { + padding: 7px 10px; + border-bottom: 1px solid rgba(124, 146, 196, 0.14); + vertical-align: top; + } + + #PostgresProviderConfigPage .pg-card table tbody tr:nth-child(even) { + background: rgba(255, 255, 255, 0.02); + } + + #PostgresProviderConfigPage :focus-visible { + outline: 2px solid var(--pg-accent); + outline-offset: 2px; + } + /* dialog styles are inline on the element itself to guarantee they apply even when Jellyfin discards the plugin */ @@ -750,7 +796,15 @@

Historial msg_query_ms_suffix: ' ms', msg_query_truncated_suffix: ' (truncated to the row limit)', msg_query_null: 'NULL', - msg_query_plan_note: 'Execution plan: the statement was planned, not executed.' + msg_query_plan_note: 'Execution plan: the statement was planned, not executed.', + field_host: 'Host', + field_port: 'Port', + field_database: 'Database', + field_username: 'Username', + field_password: 'Password', + field_pool_size: 'Max pool size', + field_command_timeout: 'Command timeout (seconds)', + field_sqlite_path: 'SQLite file path' }, es: { title_main: 'PostgreSQL Database Provider', @@ -929,7 +983,15 @@

Historial msg_query_ms_suffix: ' ms', msg_query_truncated_suffix: ' (truncado al limite de filas)', msg_query_null: 'NULL', - msg_query_plan_note: 'Plan de ejecucion: la sentencia se planifico, no se ejecuto.' + msg_query_plan_note: 'Plan de ejecucion: la sentencia se planifico, no se ejecuto.', + field_host: 'Host', + field_port: 'Puerto', + field_database: 'Base de datos', + field_username: 'Usuario', + field_password: 'Contraseña', + field_pool_size: 'Tamaño máximo del pool', + field_command_timeout: 'Timeout de comandos (segundos)', + field_sqlite_path: 'Ruta del archivo SQLite' } }; diff --git a/docs/VERIFICACION-RESUMEN.md b/docs/VERIFICACION-RESUMEN.md index 074cd79..4fe7ae0 100644 --- a/docs/VERIFICACION-RESUMEN.md +++ b/docs/VERIFICACION-RESUMEN.md @@ -5,7 +5,7 @@ | Dato | Valor | | --- | --- | -| Fecha de ejecución | 2026-09-15 20:27:21 -07:00 | +| Fecha de ejecución | 2026-09-16 02:12:02 -07:00 | | Versión del plugin | 3.0.0.0 | | Jellyfin validado | 12.1.0 | | Base de datos de pruebas | Jellyfin | @@ -15,7 +15,7 @@ | Verificaciones | Correctas | Con error | Omitidas | | --- | --- | --- | --- | -| 57 | 57 | 0 | 0 | +| 61 | 61 | 0 | 0 | ## Resultado por área @@ -28,6 +28,7 @@ | QueryConsoleTests | 26 | ✅ 26/26 | | SchemaAuditTests | 2 | ✅ 2/2 | | SqlRewriteTests | 9 | ✅ 9/9 | +| WebPageTests | 4 | ✅ 4/4 | ## Detalle @@ -90,6 +91,10 @@ | SqlRewriteTests.UnrelatedStatementsAreLeftUntouched(sql: "DELETE FROM \"LinkedChildren\" WHERE \"ParentId\" "···) | ✅ | | SqlRewriteTests.UnrelatedStatementsAreLeftUntouched(sql: "UPDATE \"BaseItems\" SET \"Name\" = 'x' WHERE \"Id"···) | ✅ | | SqlRewriteTests.UnrelatedStatementsAreLeftUntouched(sql: "UPDATE \"UserData\" SET \"Played\" = true WHERE \""···) | ✅ | +| WebPageTests.BothDictionariesContainEveryTranslatedKey | ✅ | +| WebPageTests.ConfigurationPageResourceIsEmbedded | ✅ | +| WebPageTests.EveryElementIdUsedByTheScriptExists | ✅ | +| WebPageTests.EveryTabHasItsPane | ✅ | ## Cómo reproducirlo diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/WebPageTests.cs b/tests/Jellyfin.Database.Providers.Postgres.Tests/WebPageTests.cs new file mode 100644 index 0000000..bddd4d0 --- /dev/null +++ b/tests/Jellyfin.Database.Providers.Postgres.Tests/WebPageTests.cs @@ -0,0 +1,85 @@ +using System.Text.RegularExpressions; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// The dashboard page is a single embedded HTML file, so nothing at compile time checks that the ids the +/// script reaches for exist, that every tab has a pane, that both dictionaries carry the same keys, or that +/// the embedded resource is the one the plugin advertises. These tests cover exactly that. +/// +public sealed class WebPageTests +{ + private static readonly string Html = File.ReadAllText( + Path.Combine(TestEnvironment.PluginSourceDirectory, "Web", "configurationPage.html")); + + [Fact] + public void EveryElementIdUsedByTheScriptExists() + { + var defined = Matches("id=\"([A-Za-z][A-Za-z0-9_-]*)\""); + var used = Matches("getElementById\\('([A-Za-z][A-Za-z0-9_-]*)'\\)"); + + var missing = used.Except(defined).OrderBy(id => id, StringComparer.Ordinal).ToArray(); + + Assert.Empty(missing); + } + + [Fact] + public void EveryTabHasItsPane() + { + var tabs = Matches("data-tab=\"([a-z]+)\"").OrderBy(tab => tab, StringComparer.Ordinal).ToArray(); + var panes = Matches("id=\"pg-tab-([a-z]+)\"").OrderBy(pane => pane, StringComparer.Ordinal).ToArray(); + + Assert.NotEmpty(tabs); + Assert.Equal(tabs, panes); + } + + [Fact] + public void BothDictionariesContainEveryTranslatedKey() + { + var englishStart = Html.IndexOf("en: {", StringComparison.Ordinal); + var spanishStart = Html.IndexOf("es: {", StringComparison.Ordinal); + var dictionaryEnd = Html.IndexOf("function detectLang", StringComparison.Ordinal); + + Assert.True(englishStart > 0 && spanishStart > englishStart && dictionaryEnd > spanishStart, + "No se pudieron delimitar los diccionarios i18n del panel."); + + // The blocks start after their own header, so "en:"/"es:" are not taken for keys. + var english = Keys(Html[(englishStart + "en: {".Length)..spanishStart]); + var spanish = Keys(Html[(spanishStart + "es: {".Length)..dictionaryEnd]); + + // A key missing from one of the two dictionaries shows up as an untranslated panel. + Assert.Equal( + english.OrderBy(key => key, StringComparer.Ordinal), + spanish.OrderBy(key => key, StringComparer.Ordinal)); + + // The lookbehind keeps calls like createElement('th') from being mistaken for t('th'). + var used = Matches("(? key, StringComparer.Ordinal).ToArray(); + + Assert.Empty(untranslated); + } + + /// + /// Jellyfin serves the panel from the embedded resource named by GetPages + /// (<namespace>.Web.configurationPage.html); if the file is renamed without renaming the + /// resource, the dashboard shows an empty page with no compile error. + /// + [Fact] + public void ConfigurationPageResourceIsEmbedded() + { + var expected = string.Concat(typeof(PostgresPlugin).Namespace, ".Web.configurationPage.html"); + + Assert.Contains(expected, typeof(PostgresPlugin).Assembly.GetManifestResourceNames()); + } + + private static HashSet Matches(string pattern) + => Regex.Matches(Html, pattern, RegexOptions.CultureInvariant) + .Select(match => match.Groups[1].Value) + .ToHashSet(StringComparer.Ordinal); + + private static HashSet Keys(string dictionaryBlock) + => Regex.Matches(dictionaryBlock, @"^\s*([a-z0-9_]+):", RegexOptions.Multiline | RegexOptions.CultureInvariant) + .Select(match => match.Groups[1].Value) + .ToHashSet(StringComparer.Ordinal); +} From a997938d4889d13f261fb66a8a14f44e9f1e43d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 02:18:58 -0700 Subject: [PATCH 07/15] feat(ui): historial persistente, boton para limpiarlo y plantillas sin ruido de otras bases Las plantillas que leen pg_stat_statements filtran ahora por la base actual y excluyen las sentencias de administracion, para que la consola no arrastre el ruido de trabajos ajenos (por ejemplo las bases desechables que crea la propia suite de pruebas). El historial se guarda en sessionStorage y se puede vaciar con un boton. Dos pruebas nuevas comprueban que toda plantilla del panel es una sentencia de solo lectura aceptada por la consola y que ninguna muestra estadisticas de otras bases. --- .../Web/configurationPage.html | 44 ++++++++++++-- docs/VERIFICACION-RESUMEN.md | 8 ++- tests/CompatibilityChecks/Program.cs | 41 +++++++++++++ .../QueryConsoleTests.cs | 57 +++++++++++++++++++ 4 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 tests/CompatibilityChecks/Program.cs diff --git a/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html b/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html index db31f50..56b4daa 100644 --- a/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html +++ b/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html @@ -601,8 +601,11 @@

Consola SQL de solo lectura

@@ -776,6 +779,7 @@

Historial preset_query_empty: '\u2014 Choose a template \u2014', preset_query_tables: 'Largest tables', preset_query_slow: 'Slowest queries (pg_stat_statements)', + preset_query_databases: 'Databases in the server', preset_query_activity: 'Current activity', preset_query_connections: 'Connections by state', preset_query_unused_indexes: 'Unused indexes', @@ -787,6 +791,7 @@

Historial label_query_explain: 'Plan only (EXPLAIN)', button_run_query: 'Run statement', button_clear_query: 'Clear', + button_clear_history: 'Clear history', heading_query_history: 'History', hint_query_history: 'Click an entry to load it again.', msg_query_running: 'Running statement...', @@ -963,6 +968,7 @@

Historial preset_query_empty: '\u2014 Elegir una plantilla \u2014', preset_query_tables: 'Tablas mas grandes', preset_query_slow: 'Consultas mas lentas (pg_stat_statements)', + preset_query_databases: 'Bases de datos del servidor', preset_query_activity: 'Actividad actual', preset_query_connections: 'Conexiones por estado', preset_query_unused_indexes: 'Indices sin uso', @@ -974,6 +980,7 @@

Historial label_query_explain: 'Solo plan (EXPLAIN)', button_run_query: 'Ejecutar sentencia', button_clear_query: 'Limpiar', + button_clear_history: 'Limpiar historial', heading_query_history: 'Historial', hint_query_history: 'Haz clic en una entrada para volver a cargarla.', msg_query_running: 'Ejecutando sentencia...', @@ -1152,6 +1159,7 @@

Historial setText('queryExplainLabel', t('label_query_explain', 'Plan only (EXPLAIN)')); setText('btnRunQueryLabel', t('button_run_query', 'Run statement')); setText('btnClearQueryLabel', t('button_clear_query', 'Clear')); + setText('btnClearQueryHistoryLabel', t('button_clear_history', 'Clear history')); setText('queryHistoryHeading', t('heading_query_history', 'History')); setText('queryHistoryHint', t('hint_query_history', 'Click an entry to load it again.')); buildQueryPresets(); @@ -1324,18 +1332,43 @@

Historial var QUERY_PRESETS = [ { key: 'preset_query_empty', sql: '' }, { key: 'preset_query_tables', sql: 'SELECT c.relname AS tabla, pg_size_pretty(pg_total_relation_size(c.oid)) AS total, s.n_live_tup AS filas\nFROM pg_class c\nJOIN pg_stat_user_tables s ON s.relid = c.oid\nWHERE c.relkind = \'r\'\nORDER BY pg_total_relation_size(c.oid) DESC\nLIMIT 25;' }, - { key: 'preset_query_slow', sql: 'SELECT calls AS llamadas, ROUND(mean_exec_time::numeric, 1) AS ms_media, ROUND(total_exec_time::numeric) AS ms_total, rows AS filas, LEFT(query, 120) AS consulta\nFROM pg_stat_statements\nORDER BY mean_exec_time DESC\nLIMIT 20;' }, + { key: 'preset_query_slow', sql: 'SELECT calls AS llamadas, ROUND(mean_exec_time::numeric, 1) AS ms_media, ROUND(total_exec_time::numeric) AS ms_total, rows AS filas, LEFT(query, 120) AS consulta\nFROM pg_stat_statements\nWHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())\n AND query !~* \'(create|drop) (database|tablespace)\'\n AND query NOT LIKE \'%pg_stat_statements%\'\nORDER BY total_exec_time DESC\nLIMIT 20;' }, { key: 'preset_query_activity', sql: 'SELECT pid, state AS estado, wait_event_type AS espera, LEFT(query, 100) AS consulta\nFROM pg_stat_activity\nWHERE datname = current_database() AND pid <> pg_backend_pid()\nORDER BY query_start;' }, { key: 'preset_query_connections', sql: 'SELECT COALESCE(state, \'(sin estado)\') AS estado, COUNT(*) AS conexiones\nFROM pg_stat_activity\nWHERE datname = current_database()\nGROUP BY 1\nORDER BY 2 DESC;' }, { key: 'preset_query_unused_indexes', sql: 'SELECT relname AS tabla, indexrelname AS indice, idx_scan AS usos, pg_size_pretty(pg_relation_size(indexrelid)) AS tamano\nFROM pg_stat_user_indexes\nWHERE idx_scan = 0\nORDER BY pg_relation_size(indexrelid) DESC\nLIMIT 20;' }, { key: 'preset_query_dead_tuples', sql: 'SELECT relname AS tabla, n_live_tup AS filas_vivas, n_dead_tup AS tuplas_muertas, last_autovacuum, last_autoanalyze\nFROM pg_stat_user_tables\nORDER BY n_dead_tup DESC\nLIMIT 15;' }, { key: 'preset_query_locks', sql: 'SELECT l.pid, l.locktype, l.mode, l.granted AS concedido, LEFT(a.query, 80) AS consulta\nFROM pg_locks l\nJOIN pg_stat_activity a ON a.pid = l.pid\nWHERE NOT l.granted;' }, { key: 'preset_query_extensions', sql: 'SELECT extname AS extension, extversion AS version FROM pg_extension ORDER BY extname;' }, - { key: 'preset_query_server', sql: 'SELECT current_database() AS base, current_schema() AS esquema, current_setting(\'search_path\') AS search_path, pg_size_pretty(pg_database_size(current_database())) AS tamano, version() AS version;' } + { key: 'preset_query_server', sql: 'SELECT current_database() AS base, current_schema() AS esquema, current_setting(\'search_path\') AS search_path, pg_size_pretty(pg_database_size(current_database())) AS tamano, version() AS version;' }, + { key: 'preset_query_databases', sql: 'SELECT d.datname AS base, pg_size_pretty(pg_database_size(d.datname)) AS tamano, (SELECT COUNT(*) FROM pg_stat_activity a WHERE a.datname = d.datname) AS conexiones\nFROM pg_database d\nWHERE d.datistemplate = false\nORDER BY pg_database_size(d.datname) DESC;' } ]; + // The history lives in sessionStorage so it survives a page reload, and the user can wipe it. + var QUERY_HISTORY_KEY = 'pg.queryHistory'; var queryHistoryEntries = []; + function loadQueryHistory() { + try { + var stored = JSON.parse(sessionStorage.getItem(QUERY_HISTORY_KEY) || '[]'); + if (Object.prototype.toString.call(stored) === '[object Array]') { + queryHistoryEntries = stored.filter(function(entry) { return typeof entry === 'string' && entry.length; }).slice(0, 10); + } + } catch (e) { + queryHistoryEntries = []; + } + renderQueryHistory(); + } + + function saveQueryHistory() { + try { sessionStorage.setItem(QUERY_HISTORY_KEY, JSON.stringify(queryHistoryEntries)); } catch (e) { /* storage disabled */ } + } + + function clearQueryHistory() { + queryHistoryEntries = []; + try { sessionStorage.removeItem(QUERY_HISTORY_KEY); } catch (e) { /* storage disabled */ } + renderQueryHistory(); + } + function buildQueryPresets() { var select = document.getElementById('queryTemplate'); if (!select || select.getAttribute('data-built') === '1') return; @@ -1429,6 +1462,7 @@

Historial queryHistoryEntries = queryHistoryEntries.filter(function(entry) { return entry !== sql; }); queryHistoryEntries.unshift(sql); if (queryHistoryEntries.length > 10) queryHistoryEntries.length = 10; + saveQueryHistory(); renderQueryHistory(); } @@ -1452,6 +1486,7 @@

Historial } document.getElementById('btnRunQuery').addEventListener('click', runQuery); + document.getElementById('btnClearQueryHistory').addEventListener('click', clearQueryHistory); document.getElementById('btnClearQuery').addEventListener('click', function() { document.getElementById('querySql').value = ''; document.getElementById('queryResultWrap').style.display = 'none'; @@ -2247,6 +2282,7 @@

Historial applyLocalization(); document.getElementById('currentStatusBox').textContent = t('msg_loading', 'Loading...'); + loadQueryHistory(); document.querySelector('.pg-tab-btn[data-tab="config"]').click(); loadStatus(); })(); diff --git a/docs/VERIFICACION-RESUMEN.md b/docs/VERIFICACION-RESUMEN.md index 4fe7ae0..6358f87 100644 --- a/docs/VERIFICACION-RESUMEN.md +++ b/docs/VERIFICACION-RESUMEN.md @@ -5,7 +5,7 @@ | Dato | Valor | | --- | --- | -| Fecha de ejecución | 2026-09-16 02:12:02 -07:00 | +| Fecha de ejecución | 2026-09-16 02:18:57 -07:00 | | Versión del plugin | 3.0.0.0 | | Jellyfin validado | 12.1.0 | | Base de datos de pruebas | Jellyfin | @@ -15,7 +15,7 @@ | Verificaciones | Correctas | Con error | Omitidas | | --- | --- | --- | --- | -| 61 | 61 | 0 | 0 | +| 63 | 63 | 0 | 0 | ## Resultado por área @@ -25,7 +25,7 @@ | LoggingTests | 3 | ✅ 3/3 | | PostgresIntegrationTests | 11 | ✅ 11/11 | | ProviderContractTests | 4 | ✅ 4/4 | -| QueryConsoleTests | 26 | ✅ 26/26 | +| QueryConsoleTests | 28 | ✅ 28/28 | | SchemaAuditTests | 2 | ✅ 2/2 | | SqlRewriteTests | 9 | ✅ 9/9 | | WebPageTests | 4 | ✅ 4/4 | @@ -70,6 +70,8 @@ | QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "TRUNCATE \"UserData\"") | ✅ | | QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "UPDATE \"BaseItems\" SET \"Name\" = 'x'") | ✅ | | QueryConsoleTests.MutatingOrMultiStatementSqlIsRejected(sql: "VACUUM") | ✅ | +| QueryConsoleTests.PanelTemplatesAreReadOnlyStatements | ✅ | +| QueryConsoleTests.PanelTemplatesDoNotShowStatisticsFromOtherDatabases | ✅ | | QueryConsoleTests.ReadOnlyStatementsAreAccepted(sql: "/* plan primero */ EXPLAIN SELECT 1") | ✅ | | QueryConsoleTests.ReadOnlyStatementsAreAccepted(sql: "SELECT 'UPDATE' AS palabra, \"SET\" FROM (VALUES ("···) | ✅ | | QueryConsoleTests.ReadOnlyStatementsAreAccepted(sql: "select \"Id\" from \"BaseItems\" limit 5;") | ✅ | diff --git a/tests/CompatibilityChecks/Program.cs b/tests/CompatibilityChecks/Program.cs new file mode 100644 index 0000000..9447d6f --- /dev/null +++ b/tests/CompatibilityChecks/Program.cs @@ -0,0 +1,41 @@ +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Postgres; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Logging.Abstractions; + +using var context = new JellyfinDbContext( + new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=unused;Username=unused", + pg => pg.MigrationsAssembly(typeof(PostgresDatabaseProvider).Assembly.FullName)) + .Options, + NullLogger.Instance, + new PostgresDatabaseProvider(null, null), + new NoLockBehavior(NullLogger.Instance)); + +Check(!context.Database.HasPendingModelChanges(), "Snapshot matches Jellyfin 12.1 model"); +var script = context.GetService().GenerateScript(); +Check(script.Contains("CREATE TABLE \"LinkedChildren\""), "Playlist membership schema exists"); +Check(script.Contains("USING CASE WHEN replace(\"PrimaryVersionId\""), "Legacy version IDs explicitly convert to UUID"); +Check(!script.Contains("RENAME COLUMN \"ExtraIds\" TO \"OriginalLanguage\""), "Language does not inherit legacy extra IDs"); +Check(!script.Contains("[UserId]"), "Generated PostgreSQL SQL has no SQLite identifier syntax"); +var table = context.Model.GetRelationalModel().Tables.Single(t => t.Name == "LinkedChildren"); +Check(table.PrimaryKey!.Columns.Select(c => c.Name).SequenceEqual(new[] { "ParentId", "SortOrder" }), + "Playlists support repeated children at different positions"); + +// Contracts the plugin relies on in the Jellyfin server itself, not in the database engine. +ContractChecks.VerifyServerContract(); +ContractChecks.VerifyDatabaseConfigurationContract(); +ContractChecks.VerifyVersionAlignment(); +ContractChecks.VerifyRawSqlSchema(context); +ContractChecks.VerifyLogging(); +var connection = Environment.GetEnvironmentVariable("POSTGRES_TEST_CONNECTION"); +if (!string.IsNullOrWhiteSpace(connection)) await PostgresChecks.RunAsync(connection); +Console.WriteLine("All compatibility checks passed."); +static void Check(bool condition, string description) +{ + if (!condition) throw new InvalidOperationException(description); + Console.WriteLine($"PASS: {description}"); +} diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs b/tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs index 6cea71a..1d7807d 100644 --- a/tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs +++ b/tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using Jellyfin.Database.Providers.Postgres.Services; using Xunit; @@ -60,6 +61,62 @@ public void KeywordsHiddenInLiteralsAreNotRejected() Assert.Equal(sql, QueryConsoleService.Validate(sql)); } + /// + /// The templates the panel offers have to be accepted by the console itself: a template using a blocked + /// keyword would be rejected the moment an administrator picked it. + /// + [Fact] + public void PanelTemplatesAreReadOnlyStatements() + { + var presets = PanelTemplates(); + + Assert.True(presets.Count >= 8, $"Se esperaban al menos 8 plantillas en el panel, encontradas {presets.Count}."); + foreach (var preset in presets.Where(p => !string.IsNullOrWhiteSpace(p.Sql))) + { + Assert.False( + string.IsNullOrWhiteSpace(QueryConsoleService.Validate(preset.Sql)), + $"La plantilla '{preset.Key}' quedó vacía al validarse."); + } + } + + /// + /// pg_stat_statements accumulates statements from every database of the server, so the templates that read + /// it must filter by the current database. Without the filter the panel shows unrelated maintenance noise + /// (creating and dropping scratch databases, for instance). + /// + [Fact] + public void PanelTemplatesDoNotShowStatisticsFromOtherDatabases() + { + var presets = PanelTemplates() + .Where(p => p.Sql.Contains("pg_stat_statements", StringComparison.Ordinal)) + .ToArray(); + + Assert.NotEmpty(presets); + foreach (var preset in presets) + { + Assert.Contains("dbid", preset.Sql, StringComparison.Ordinal); + } + } + + private static List<(string Key, string Sql)> PanelTemplates() + { + var html = File.ReadAllText(Path.Combine(TestEnvironment.PluginSourceDirectory, "Web", "configurationPage.html")); + var presets = new List<(string Key, string Sql)>(); + + foreach (Match match in Regex.Matches( + html, + @"\{\s*key:\s*'(preset_[a-z0-9_]+)',\s*sql:\s*'((?:[^'\\]|\\.)*)'\s*\}", + RegexOptions.CultureInvariant)) + { + presets.Add((match.Groups[1].Value, UnescapeSql(match.Groups[2].Value))); + } + + return presets; + } + + private static string UnescapeSql(string value) + => value.Replace("\\'", "'", StringComparison.Ordinal).Replace("\\n", "\n", StringComparison.Ordinal); + /// /// Validating must not change what is executed: quoted identifiers of the Jellyfin schema are preserved /// (a previous version returned the masked statement, which broke every quoted table name). From d29d224dca2f4231e51995b700ab61268d01c904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 02:19:19 -0700 Subject: [PATCH 08/15] chore: retirar el runner de comprobaciones sustituido por la suite xUnit tests/CompatibilityChecks quedo huerfano al migrar las comprobaciones a Jellyfin.Database.Providers.Postgres.Tests y no aparece en la solucion; se elimina para no mantener dos runners en el repositorio. --- tests/CompatibilityChecks/Program.cs | 41 ---------------------------- 1 file changed, 41 deletions(-) delete mode 100644 tests/CompatibilityChecks/Program.cs diff --git a/tests/CompatibilityChecks/Program.cs b/tests/CompatibilityChecks/Program.cs deleted file mode 100644 index 9447d6f..0000000 --- a/tests/CompatibilityChecks/Program.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Jellyfin.Database.Implementations; -using Jellyfin.Database.Implementations.Locking; -using Jellyfin.Database.Providers.Postgres; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.Extensions.Logging.Abstractions; - -using var context = new JellyfinDbContext( - new DbContextOptionsBuilder() - .UseNpgsql("Host=localhost;Database=unused;Username=unused", - pg => pg.MigrationsAssembly(typeof(PostgresDatabaseProvider).Assembly.FullName)) - .Options, - NullLogger.Instance, - new PostgresDatabaseProvider(null, null), - new NoLockBehavior(NullLogger.Instance)); - -Check(!context.Database.HasPendingModelChanges(), "Snapshot matches Jellyfin 12.1 model"); -var script = context.GetService().GenerateScript(); -Check(script.Contains("CREATE TABLE \"LinkedChildren\""), "Playlist membership schema exists"); -Check(script.Contains("USING CASE WHEN replace(\"PrimaryVersionId\""), "Legacy version IDs explicitly convert to UUID"); -Check(!script.Contains("RENAME COLUMN \"ExtraIds\" TO \"OriginalLanguage\""), "Language does not inherit legacy extra IDs"); -Check(!script.Contains("[UserId]"), "Generated PostgreSQL SQL has no SQLite identifier syntax"); -var table = context.Model.GetRelationalModel().Tables.Single(t => t.Name == "LinkedChildren"); -Check(table.PrimaryKey!.Columns.Select(c => c.Name).SequenceEqual(new[] { "ParentId", "SortOrder" }), - "Playlists support repeated children at different positions"); - -// Contracts the plugin relies on in the Jellyfin server itself, not in the database engine. -ContractChecks.VerifyServerContract(); -ContractChecks.VerifyDatabaseConfigurationContract(); -ContractChecks.VerifyVersionAlignment(); -ContractChecks.VerifyRawSqlSchema(context); -ContractChecks.VerifyLogging(); -var connection = Environment.GetEnvironmentVariable("POSTGRES_TEST_CONNECTION"); -if (!string.IsNullOrWhiteSpace(connection)) await PostgresChecks.RunAsync(connection); -Console.WriteLine("All compatibility checks passed."); -static void Check(bool condition, string description) -{ - if (!condition) throw new InvalidOperationException(description); - Console.WriteLine($"PASS: {description}"); -} From 129fbaa600a533bc3bd782edd89506a0e3fead12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 02:50:45 -0700 Subject: [PATCH 09/15] fix(ui): opciones avanzadas efectivas y documentacion de la migracion a Jellyfin 12 Opciones avanzadas (pool y timeout): - SaveConfig valida (max >= min) y persiste MinPoolSize, MaxPoolSize y MaxAutoPrepare. - El proveedor respeta la cadena de conexion y solo completa huecos con la configuracion guardada, que era la razon por la que editar el pool no tenia efecto. - Current status refleja pool, timeout y sentencias preparadas. - 5 pruebas nuevas en AdvancedOptionTests. Rutas y limpieza: - tests -> Tests y tools -> Tools, con workflows, solucion e informes alineados. - build.yaml ejecuta la suite xUnit en lugar del runner retirado. - .gitignore y verificacion apuntan a Tests/TestResults. Publicacion: - release.yml replica la entrada del catalogo en main para que el release sea visible. Documentacion (ES principal + EN): - Manual de migracion 10.11.x -> 12.x con diagrama, contingencias y rollback. - Novedades de 3.0.0 frente a 2.0.1. - Plan de publicacion de 2.0.1 y 3.0.0 (orden obligatorio y verificacion posterior). Suite de verificacion: 68 pruebas, 0 fallos. --- .github/workflows/build.yaml | 4 +- .github/workflows/release.yml | 35 ++- .github/workflows/verification.yml | 4 +- .gitignore | 2 +- Jellyfin.Database.Providers.Postgres.sln | 4 +- .../Controllers/Models/SaveConfigRequest.cs | 9 + .../Controllers/PostgresController.cs | 26 ++ .../Data/PostgresDatabaseProvider.cs | 52 +++- .../Web/configurationPage.html | 63 +++- README.en.md | 13 + README.md | 13 + .../AdvancedOptionTests.cs | 92 ++++++ .../DatabaseConfigurationTests.cs | 0 ...n.Database.Providers.Postgres.Tests.csproj | 0 .../LoggingTests.cs | 0 .../PostgresIntegrationTests.cs | 0 .../ProviderContractTests.cs | 0 .../QueryConsoleTests.cs | 0 .../SchemaAuditTests.cs | 0 .../SqlRewriteTests.cs | 0 .../TestEnvironment.cs | 0 .../TestPaths.cs | 0 .../WebPageTests.cs | 0 {tools => Tools}/Generar-ResumenEjecutivo.ps1 | 12 +- docs/JELLYFIN-12.1.md | 7 +- docs/VERIFICACION-RESUMEN.md | 16 +- docs/migracion-10.11-a-12.md | 279 ++++++++++++++++++ docs/migration-10.11-to-12.en.md | 272 +++++++++++++++++ docs/novedades-3.0.0.md | 172 +++++++++++ docs/plan-de-publicacion.md | 182 ++++++++++++ docs/release-plan.en.md | 179 +++++++++++ docs/whats-new-3.0.0.en.md | 166 +++++++++++ 32 files changed, 1566 insertions(+), 36 deletions(-) create mode 100644 Tests/Jellyfin.Database.Providers.Postgres.Tests/AdvancedOptionTests.cs rename {tests => Tests}/Jellyfin.Database.Providers.Postgres.Tests/DatabaseConfigurationTests.cs (100%) rename {tests => Tests}/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj (100%) rename {tests => Tests}/Jellyfin.Database.Providers.Postgres.Tests/LoggingTests.cs (100%) rename {tests => Tests}/Jellyfin.Database.Providers.Postgres.Tests/PostgresIntegrationTests.cs (100%) rename {tests => Tests}/Jellyfin.Database.Providers.Postgres.Tests/ProviderContractTests.cs (100%) rename {tests => Tests}/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs (100%) rename {tests => Tests}/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs (100%) rename {tests => Tests}/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs (100%) rename {tests => Tests}/Jellyfin.Database.Providers.Postgres.Tests/TestEnvironment.cs (100%) rename {tests => Tests}/Jellyfin.Database.Providers.Postgres.Tests/TestPaths.cs (100%) rename {tests => Tests}/Jellyfin.Database.Providers.Postgres.Tests/WebPageTests.cs (100%) rename {tools => Tools}/Generar-ResumenEjecutivo.ps1 (95%) create mode 100644 docs/migracion-10.11-a-12.md create mode 100644 docs/migration-10.11-to-12.en.md create mode 100644 docs/novedades-3.0.0.md create mode 100644 docs/plan-de-publicacion.md create mode 100644 docs/release-plan.en.md create mode 100644 docs/whats-new-3.0.0.en.md diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a18ce35..bac990e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -45,10 +45,10 @@ jobs: - name: Build (Release) run: dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release --no-restore - - name: Compatibility and migration checks + - name: Suite de verificación env: POSTGRES_TEST_CONNECTION: Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres - run: dotnet run --project tests/CompatibilityChecks -c Release --no-build + run: dotnet test Tests/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj -c Release --no-build --no-restore - name: Publish run: dotnet publish Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj -c Release --no-restore -o artifacts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0fa236f..d82536d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,13 +81,15 @@ jobs: - name: Actualizar manifest.json (repo) run: | VERSION="${{ steps.version.outputs.full }}" + # ABI que declara esta rama: 12.1.0.0 corresponde a Jellyfin 12.x. + TARGETABI="12.1.0.0" ZIP="Jellyfin.Database.Providers.Postgres-${VERSION}.zip" URL="https://github.com/${{ github.repository }}/releases/download/v${{ github.event.inputs.release_version }}/${ZIP}" CHECKSUM="${{ steps.package.outputs.checksum }}" TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") CHANGELOG="${{ github.event.inputs.changelog }}" - jq --arg v "$VERSION" --arg url "$URL" --arg sum "$CHECKSUM" --arg ts "$TS" --arg changelog "$CHANGELOG" \ - '.[0].versions = (([.[0].versions[] | select(.version != $v)]) + [{version: $v, changelog: $changelog, targetAbi: "12.1.0.0", sourceUrl: $url, checksum: $sum, timestamp: $ts}])' \ + jq --arg v "$VERSION" --arg url "$URL" --arg sum "$CHECKSUM" --arg ts "$TS" --arg changelog "$CHANGELOG" --arg abi "$TARGETABI" \ + '.[0].versions = (([.[0].versions[] | select(.version != $v)]) + [{version: $v, changelog: $changelog, targetAbi: $abi, sourceUrl: $url, checksum: $sum, timestamp: $ts}])' \ manifest.json > manifest.tmp.json && mv manifest.tmp.json manifest.json cp manifest.json publish/manifest.json @@ -99,6 +101,35 @@ jobs: git diff --cached --quiet || git commit -m "chore: actualizar manifest a v${{ steps.version.outputs.full }}" git push + # El catálogo que consulta Jellyfin es el manifest.json de main. Si el release se + # publica desde una rama (release/*), la entrada debe replicarse en main o la + # versión no aparece en el catálogo aunque el release exista. + - name: Publicar la entrada en el catálogo servido (main) + if: github.ref_name != 'main' + env: + TARGETABI: '12.1.0.0' + run: | + VERSION="${{ steps.version.outputs.full }}" + ZIP="Jellyfin.Database.Providers.Postgres-${VERSION}.zip" + URL="https://github.com/${{ github.repository }}/releases/download/v${{ github.event.inputs.release_version }}/${ZIP}" + CHECKSUM="${{ steps.package.outputs.checksum }}" + TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + CHANGELOG="${{ github.event.inputs.changelog }}" + git fetch origin main + git worktree add --detach ../catalogo origin/main + cd ../catalogo + jq --arg v "$VERSION" --arg url "$URL" --arg sum "$CHECKSUM" --arg ts "$TS" --arg changelog "$CHANGELOG" --arg abi "$TARGETABI" \ + '.[0].versions = (([.[0].versions[] | select(.version != $v)]) + [{version: $v, changelog: $changelog, targetAbi: $abi, sourceUrl: $url, checksum: $sum, timestamp: $ts}])' \ + manifest.json > manifest.tmp.json && mv manifest.tmp.json manifest.json + git add manifest.json + if git diff --cached --quiet; then + echo "manifest.json de main ya contiene v${VERSION}" + exit 0 + fi + git -c user.name="github-actions[bot]" -c user.email="github-actions[bot]@users.noreply.github.com" \ + commit -m "chore: publicar v${VERSION} en el catálogo (targetAbi ${TARGETABI})" + git push origin HEAD:main + - name: Preparar notas del release run: printf '%s' "${{ github.event.inputs.changelog }}" > release-notes.md diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml index 7506fa7..db8aa52 100644 --- a/.github/workflows/verification.yml +++ b/.github/workflows/verification.yml @@ -45,7 +45,7 @@ jobs: shell: pwsh env: POSTGRES_TEST_CONNECTION: Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres - run: ./tools/Generar-ResumenEjecutivo.ps1 + run: ./Tools/Generar-ResumenEjecutivo.ps1 - name: Publicar el resumen en el job if: always() @@ -62,5 +62,5 @@ jobs: name: verificacion path: | docs/VERIFICACION-RESUMEN.md - tests/TestResults/*.trx + Tests/TestResults/*.trx if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 5efaaaf..c8cbcf6 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,7 @@ project.fragment.lock.json artifacts/ # Test results (artefactos de la suite de verificacion) -tests/TestResults/ +Tests/TestResults/ *.trx # Publish output diff --git a/Jellyfin.Database.Providers.Postgres.sln b/Jellyfin.Database.Providers.Postgres.sln index 3b070a5..f0d2b3a 100644 --- a/Jellyfin.Database.Providers.Postgres.sln +++ b/Jellyfin.Database.Providers.Postgres.sln @@ -5,9 +5,9 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Postgres", "Jellyfin.Database.Providers.Postgres\Jellyfin.Database.Providers.Postgres.csproj", "{D4FAA44B-984A-4FB7-810E-EC3315821AE8}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Postgres.Tests", "tests\Jellyfin.Database.Providers.Postgres.Tests\Jellyfin.Database.Providers.Postgres.Tests.csproj", "{B4B57747-951A-4026-985F-772CE174A3AD}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Postgres.Tests", "Tests\Jellyfin.Database.Providers.Postgres.Tests\Jellyfin.Database.Providers.Postgres.Tests.csproj", "{B4B57747-951A-4026-985F-772CE174A3AD}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/Models/SaveConfigRequest.cs b/Jellyfin.Database.Providers.Postgres/Controllers/Models/SaveConfigRequest.cs index 1c335c8..65ced19 100644 --- a/Jellyfin.Database.Providers.Postgres/Controllers/Models/SaveConfigRequest.cs +++ b/Jellyfin.Database.Providers.Postgres/Controllers/Models/SaveConfigRequest.cs @@ -9,6 +9,15 @@ public sealed class SaveConfigRequest /// Gets or sets the EF command timeout in seconds. public int CommandTimeout { get; set; } = 60; + /// Gets or sets the minimum number of connections kept open in the Npgsql pool. + public int MinPoolSize { get; set; } = 4; + + /// Gets or sets the maximum number of connections of the Npgsql pool. + public int MaxPoolSize { get; set; } = 100; + + /// Gets or sets how many statements Npgsql keeps prepared per connection. + public int MaxAutoPrepare { get; set; } = 50; + /// Gets or sets the backup output directory on the server. public string? BackupDirectory { get; set; } diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs b/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs index c568862..8670eec 100644 --- a/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs +++ b/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs @@ -99,6 +99,9 @@ public ActionResult GetStatus() SavedConnectionString = config?.ConnectionString, SavedSchema = config?.Schema ?? DefaultSchema, SavedCommandTimeout = config?.CommandTimeout ?? 60, + SavedMinPoolSize = config?.MinPoolSize ?? 4, + SavedMaxPoolSize = config?.MaxPoolSize ?? 100, + SavedMaxAutoPrepare = config?.MaxAutoPrepare ?? 50, SavedBackupDirectory = string.IsNullOrWhiteSpace(config?.BackupDirectory) ? defaultBackupDir : config?.BackupDirectory ?? defaultBackupDir, @@ -166,8 +169,31 @@ public ActionResult SaveConfig([FromBody] SaveConfigRequest request) return StatusCode(StatusCodes.Status503ServiceUnavailable, new { Error = "Plugin not loaded." }); } + if (request.MaxPoolSize > 0 && request.MinPoolSize > request.MaxPoolSize) + { + return BadRequest(new { Success = false, Error = "El tamaño máximo del pool no puede ser menor que el mínimo." }); + } + plugin.Configuration.ConnectionString = request.ConnectionString; plugin.Configuration.CommandTimeout = request.CommandTimeout; + + // Advanced options are persisted here; the provider uses them as the default for connection + // strings that do not carry them, which is what made the pool fields look like they did nothing. + if (request.MinPoolSize > 0) + { + plugin.Configuration.MinPoolSize = request.MinPoolSize; + } + + if (request.MaxPoolSize > 0) + { + plugin.Configuration.MaxPoolSize = request.MaxPoolSize; + } + + if (request.MaxAutoPrepare >= 0) + { + plugin.Configuration.MaxAutoPrepare = request.MaxAutoPrepare; + } + if (request.BackupDirectory is not null) { plugin.Configuration.BackupDirectory = request.BackupDirectory; diff --git a/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs b/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs index a594d88..d5fbc10 100644 --- a/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs +++ b/Jellyfin.Database.Providers.Postgres/Data/PostgresDatabaseProvider.cs @@ -148,13 +148,7 @@ public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOpt // Apply pool tuning and prepared-statement cache from plugin config var config = PostgresPlugin.Instance?.Configuration; var commandTimeout = config?.CommandTimeout ?? 600; - var csb = new NpgsqlConnectionStringBuilder(connStr) - { - MinPoolSize = config?.MinPoolSize ?? 4, - MaxPoolSize = config?.MaxPoolSize ?? 100, - MaxAutoPrepare = config?.MaxAutoPrepare ?? 50, - CommandTimeout = commandTimeout, - }; + var csb = BuildTunedConnectionString(connStr, config, commandTimeout); var tunedConnStr = csb.ToString(); @@ -223,6 +217,50 @@ public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOpt } } + /// + /// Builds the connection string the provider actually opens connections with, combining the saved + /// connection string with the advanced options of the plugin configuration. + /// + /// + /// A value already present in the connection string wins: the dashboard writes the pool settings there, + /// so overriding them with the plugin defaults was the reason editing "Max pool size" appeared to do + /// nothing. The configuration only fills in what the connection string does not specify. + /// + /// Connection string saved in the plugin configuration or in database.xml. + /// Plugin configuration; may be null before the plugin is loaded. + /// Command timeout in seconds; ignored when not positive. + /// The connection string to use. + internal static NpgsqlConnectionStringBuilder BuildTunedConnectionString( + string connectionString, + PluginConfiguration? config, + int commandTimeout) + { + var builder = new NpgsqlConnectionStringBuilder(connectionString); + + // ShouldSerialize tells whether the key was explicitly present in the connection string. + if (!builder.ShouldSerialize("Minimum Pool Size") && (config?.MinPoolSize ?? 0) > 0) + { + builder.MinPoolSize = config!.MinPoolSize; + } + + if (!builder.ShouldSerialize("Maximum Pool Size") && (config?.MaxPoolSize ?? 0) > 0) + { + builder.MaxPoolSize = config!.MaxPoolSize; + } + + if (!builder.ShouldSerialize("Max Auto Prepare") && (config?.MaxAutoPrepare ?? 0) > 0) + { + builder.MaxAutoPrepare = config!.MaxAutoPrepare; + } + + if (commandTimeout > 0) + { + builder.CommandTimeout = commandTimeout; + } + + return builder; + } + private static async Task LogPostgresTimezoneAsync(string connectionString) { try diff --git a/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html b/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html index 56b4daa..37107e8 100644 --- a/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html +++ b/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html @@ -283,12 +283,20 @@

Conexion PostgreSQL

- + +
+
+
Numero maximo de conexiones en el pool de Npgsql.
- + +
Cuantas sentencias mantiene Npgsql preparadas en cada conexion. 0 las desactiva.
+
+
+
+
Estas opciones se aplican al reiniciar Jellyfin.
@@ -809,6 +817,14 @@

Historial

field_password: 'Password', field_pool_size: 'Max pool size', field_command_timeout: 'Command timeout (seconds)', + field_min_pool_size: 'Min pool size', + field_max_auto_prepare: 'Prepared statements per connection', + help_max_auto_prepare: 'How many statements Npgsql keeps prepared on each connection. 0 disables it.', + help_advanced_restart: 'These options are applied when Jellyfin restarts.', + msg_advanced_invalid_pool: 'Max pool size cannot be smaller than min pool size.', + msg_pool_state_prefix: 'Pool: ', + msg_timeout_state_prefix: 'Timeout: ', + msg_prepare_state_prefix: 'Prepared statements: ', field_sqlite_path: 'SQLite file path' }, es: { @@ -998,6 +1014,14 @@

Historial

field_password: 'Contraseña', field_pool_size: 'Tamaño máximo del pool', field_command_timeout: 'Timeout de comandos (segundos)', + field_min_pool_size: 'Tamano minimo del pool', + field_max_auto_prepare: 'Sentencias preparadas por conexion', + help_max_auto_prepare: 'Cuantas sentencias mantiene Npgsql preparadas en cada conexion. 0 las desactiva.', + help_advanced_restart: 'Estas opciones se aplican al reiniciar Jellyfin.', + msg_advanced_invalid_pool: 'El tamano maximo del pool no puede ser menor que el minimo.', + msg_pool_state_prefix: 'Pool: ', + msg_timeout_state_prefix: 'Timeout: ', + msg_prepare_state_prefix: 'Sentencias preparadas: ', field_sqlite_path: 'Ruta del archivo SQLite' } }; @@ -1134,6 +1158,10 @@

Historial

setInputLabel('pgPassword', t('field_password', 'Password', 'LabelPassword')); setInputLabel('pgPoolSize', t('field_pool_size', 'Max pool size')); setInputLabel('pgCommandTimeout', t('field_command_timeout', 'Command Timeout (seconds)')); + setInputLabel('pgMinPoolSize', t('field_min_pool_size', 'Min pool size')); + setInputLabel('pgMaxAutoPrepare', t('field_max_auto_prepare', 'Prepared statements per connection')); + setText('maxAutoPrepareHelp', t('help_max_auto_prepare', 'How many statements Npgsql keeps prepared on each connection. 0 disables it.')); + setText('advancedRestartHelp', t('help_advanced_restart', 'These options are applied when Jellyfin restarts.')); setInputLabel('sqlitePath', t('field_sqlite_path', 'SQLite file path')); setInputLabel('restoreBackupPath', t('field_restore_backup_path', 'Backup path to restore (.sql or .zip)')); setInputLabel('configBackupDirectory', t('field_backup_directory_default', 'Default backup directory')); @@ -1190,13 +1218,17 @@

Historial

var db = (document.getElementById('pgDatabase').value || '').trim(); var user = (document.getElementById('pgUsername').value || '').trim(); var pass = (document.getElementById('pgPassword').value || ''); - var pool = (document.getElementById('pgPoolSize').value || '').trim() || '200'; + var pool = (document.getElementById('pgPoolSize').value || '').trim() || '100'; + var minPool = (document.getElementById('pgMinPoolSize').value || '').trim() || '4'; + var prepare = (document.getElementById('pgMaxAutoPrepare').value || '').trim() || '50'; var parts = ['Host=' + host, 'Port=' + port]; if (db) parts.push('Database=' + db); if (user) parts.push('Username=' + user); if (pass) parts.push('Password=' + pass); parts.push('Pooling=true'); + parts.push('Minimum Pool Size=' + minPool); parts.push('Maximum Pool Size=' + pool); + parts.push('Max Auto Prepare=' + prepare); return parts.join(';'); } @@ -1208,7 +1240,9 @@

Historial

document.getElementById('pgDatabase').value = p['database'] || p['initialcatalog'] || ''; document.getElementById('pgUsername').value = p['username'] || p['userid'] || p['uid'] || ''; document.getElementById('pgPassword').value = p['password'] || p['pwd'] || ''; - document.getElementById('pgPoolSize').value = p['maximumpoolsize'] || p['maxpoolsize'] || '200'; + document.getElementById('pgPoolSize').value = p['maximumpoolsize'] || p['maxpoolsize'] || '100'; + document.getElementById('pgMinPoolSize').value = p['minimumpoolsize'] || p['minpoolsize'] || '4'; + document.getElementById('pgMaxAutoPrepare').value = p['maxautoprepare'] || '50'; } document.querySelectorAll('.pg-tab-btn').forEach(function(btn) { @@ -1522,6 +1556,17 @@

Historial

fillFieldsFromConnStr(data.SavedConnectionString); if (data.SavedCommandTimeout != null) document.getElementById('pgCommandTimeout').value = data.SavedCommandTimeout; + if (data.SavedMinPoolSize != null) document.getElementById('pgMinPoolSize').value = data.SavedMinPoolSize; + if (data.SavedMaxPoolSize != null) document.getElementById('pgPoolSize').value = data.SavedMaxPoolSize; + if (data.SavedMaxAutoPrepare != null) document.getElementById('pgMaxAutoPrepare').value = data.SavedMaxAutoPrepare; + + // The advanced options were invisible in the status, which made saving them look like a no-op. + box.innerHTML += '
' + + escapeHtml(t('msg_pool_state_prefix', 'Pool: ')) + '' + escapeHtml(String(data.SavedMinPoolSize != null ? data.SavedMinPoolSize : 4)) + + '-' + escapeHtml(String(data.SavedMaxPoolSize != null ? data.SavedMaxPoolSize : 100)) + '' + + ' \u00b7 ' + escapeHtml(t('msg_timeout_state_prefix', 'Timeout: ')) + '' + escapeHtml(String(data.SavedCommandTimeout != null ? data.SavedCommandTimeout : 600)) + ' s' + + ' \u00b7 ' + escapeHtml(t('msg_prepare_state_prefix', 'Prepared statements: ')) + '' + escapeHtml(String(data.SavedMaxAutoPrepare != null ? data.SavedMaxAutoPrepare : 50)) + '' + + ''; if (data.SqliteDefaultPath) document.getElementById('sqlitePath').value = data.SqliteDefaultPath; if (data.SavedBackupDirectory) { document.getElementById('configBackupDirectory').value = data.SavedBackupDirectory; @@ -1567,14 +1612,21 @@

Historial

document.getElementById('btnSaveConfig').addEventListener('click', function() { var connStr = buildConnStr(); - var timeout = parseInt(document.getElementById('pgCommandTimeout').value, 10); if (isNaN(timeout) || timeout < 0) timeout = 60; + var timeout = parseInt(document.getElementById('pgCommandTimeout').value, 10); if (isNaN(timeout) || timeout < 0) timeout = 600; + var minPool = parseInt(document.getElementById('pgMinPoolSize').value, 10); if (isNaN(minPool) || minPool < 0) minPool = 4; + var maxPool = parseInt(document.getElementById('pgPoolSize').value, 10); if (isNaN(maxPool) || maxPool < 1) maxPool = 100; + var maxPrepare = parseInt(document.getElementById('pgMaxAutoPrepare').value, 10); if (isNaN(maxPrepare) || maxPrepare < 0) maxPrepare = 50; var backupDirectory = document.getElementById('configBackupDirectory').value.trim(); var backupCompression = !!document.getElementById('configBackupCompress').checked; var pgBinPath = document.getElementById('configPgBinPath').value.trim(); if (!document.getElementById('pgHost').value.trim()) { showBanner(t('msg_enter_host', 'Enter the PostgreSQL host.'), 'error'); return; } + if (minPool > maxPool) { showBanner(t('msg_advanced_invalid_pool', 'El tamano maximo del pool no puede ser menor que el minimo.'), 'error'); return; } apiPost('/SaveConfig', { connectionString: connStr, commandTimeout: timeout, + minPoolSize: minPool, + maxPoolSize: maxPool, + maxAutoPrepare: maxPrepare, backupDirectory: backupDirectory, backupCompression: backupCompression, pgBinPath: pgBinPath @@ -1583,6 +1635,7 @@

Historial

.then(function(data) { if (data.Success) { showBanner(t('msg_connection_saved', 'Configuration saved.'), 'success'); + loadStatus(); } else { showBanner(t('msg_connection_save_error', 'Error while saving: ') + (data.Error || ''), 'error'); } diff --git a/README.en.md b/README.en.md index 09d83dc..260c328 100644 --- a/README.en.md +++ b/README.en.md @@ -25,7 +25,20 @@ automatic health check, scheduled maintenance and optional JellyTrend integratio > **v3 / Jellyfin 12.1:** requires .NET 10 and PostgreSQL 16+. Read the [upgrade and test guide (Spanish)](docs/JELLYFIN-12.1.md) before replacing the provider. +> +> **Coming from Jellyfin 10.11.x on PostgreSQL?** Follow the [10.11.x → 12.x migration manual](docs/migration-10.11-to-12.en.md): you need plugin **2.0.1** to export PostgreSQL to SQLite before upgrading the server, and plugin **3.0.0** to import back into PostgreSQL. +--- + +## 📚 Documentation + +| Document | Content | +|---|---| +| [Migration 10.11.x → 12.x](docs/migration-10.11-to-12.en.md) · [ES](docs/migracion-10.11-a-12.md) | Step-by-step manual with diagram and contingency plan | +| [What is new in 3.0.0](docs/whats-new-3.0.0.en.md) · [ES](docs/novedades-3.0.0.md) | Every change in 3.0.0 vs 2.0.1 | +| [Release plan](docs/release-plan.en.md) · [ES](docs/plan-de-publicacion.md) | Order and runbook to publish 2.0.1 and 3.0.0 | +| [Jellyfin 12.1 adaptation](docs/JELLYFIN-12.1.md) | Technical notes and how to run the test suite | +| [Automated verification](docs/VERIFICACION-RESUMEN.md) | Executive summary generated from the real suite | --- diff --git a/README.md b/README.md index 614531e..b73d0b3 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,20 @@ health check automático, mantenimiento programado e integración opcional con J > **v3 / Jellyfin 12.1:** requiere .NET 10 y PostgreSQL 16+. Consulta la [guía de actualización y pruebas](docs/JELLYFIN-12.1.md) antes de sustituir el proveedor. +> +> **¿Vienes de Jellyfin 10.11.x con PostgreSQL?** Sigue el [manual de migración 10.11.x → 12.x](docs/migracion-10.11-a-12.md): necesitas el plugin **2.0.1** para exportar PostgreSQL a SQLite antes de actualizar el servidor y el plugin **3.0.0** para importar de vuelta a PostgreSQL. +--- + +## 📚 Documentación + +| Documento | Contenido | +|---|---| +| [Migración 10.11.x → 12.x](docs/migracion-10.11-a-12.md) · [EN](docs/migration-10.11-to-12.en.md) | Manual paso a paso, con diagrama y plan de contingencia | +| [Novedades de 3.0.0](docs/novedades-3.0.0.md) · [EN](docs/whats-new-3.0.0.en.md) | Qué cambia en 3.0.0 frente a 2.0.1 | +| [Plan de publicación](docs/plan-de-publicacion.md) · [EN](docs/release-plan.en.md) | Orden y runbook para publicar 2.0.1 y 3.0.0 | +| [Adaptación a Jellyfin 12.1](docs/JELLYFIN-12.1.md) | Notas técnicas y cómo ejecutar la suite de pruebas | +| [Verificación automatizada](docs/VERIFICACION-RESUMEN.md) | Resumen ejecutivo generado desde la suite real | --- diff --git a/Tests/Jellyfin.Database.Providers.Postgres.Tests/AdvancedOptionTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/AdvancedOptionTests.cs new file mode 100644 index 0000000..56aa31d --- /dev/null +++ b/Tests/Jellyfin.Database.Providers.Postgres.Tests/AdvancedOptionTests.cs @@ -0,0 +1,92 @@ +using Jellyfin.Database.Providers.Postgres.Controllers.Models; +using Npgsql; +using Xunit; + +namespace Jellyfin.Database.Providers.Postgres.Tests; + +/// +/// The advanced options (pool size, prepared statement cache, timeout) are the ones an administrator tunes +/// from the dashboard. These tests pin the rule that made them work: what the connection string states is +/// respected, and the plugin configuration only fills in what it does not. +/// +public sealed class AdvancedOptionTests +{ + private const string BaseConnectionString = "Host=127.0.0.1;Port=5432;Database=Jellyfin;Username=Jellyfin;Password=x"; + + /// + /// The dashboard writes the pool into the connection string, so the provider must not overwrite it with + /// its own defaults (that is exactly why editing "Max pool size" changed nothing). + /// + [Fact] + public void PoolFromTheConnectionStringIsRespected() + { + var config = new PluginConfiguration { MinPoolSize = 4, MaxPoolSize = 100, MaxAutoPrepare = 50 }; + + var tuned = PostgresDatabaseProvider.BuildTunedConnectionString( + string.Concat(BaseConnectionString, ";Minimum Pool Size=10;Maximum Pool Size=200;Max Auto Prepare=80"), + config, + 600); + + Assert.Equal(10, tuned.MinPoolSize); + Assert.Equal(200, tuned.MaxPoolSize); + Assert.Equal(80, tuned.MaxAutoPrepare); + } + + /// + /// When the connection string does not mention the pool, the saved configuration is the source. + /// + [Fact] + public void PoolFallsBackToTheSavedConfiguration() + { + var config = new PluginConfiguration { MinPoolSize = 6, MaxPoolSize = 250, MaxAutoPrepare = 120 }; + + var tuned = PostgresDatabaseProvider.BuildTunedConnectionString(BaseConnectionString, config, 600); + + Assert.Equal(6, tuned.MinPoolSize); + Assert.Equal(250, tuned.MaxPoolSize); + Assert.Equal(120, tuned.MaxAutoPrepare); + } + + /// + /// The command timeout is always applied from the saved option: it is what keeps Jellyfin's long code + /// migrations from aborting halfway. + /// + [Fact] + public void CommandTimeoutIsAlwaysApplied() + { + var tuned = PostgresDatabaseProvider.BuildTunedConnectionString( + string.Concat(BaseConnectionString, ";Command Timeout=30"), + new PluginConfiguration(), + 600); + + Assert.Equal(600, tuned.CommandTimeout); + } + + [Fact] + public void SavedConfigurationSurvivesTheRoundTrip() + { + var config = new PluginConfiguration { MinPoolSize = 8, MaxPoolSize = 300, MaxAutoPrepare = 200 }; + + var tuned = PostgresDatabaseProvider.BuildTunedConnectionString(BaseConnectionString, config, 300); + var reopened = PostgresDatabaseProvider.BuildTunedConnectionString(tuned.ConnectionString, new PluginConfiguration(), 300); + + Assert.Equal(8, reopened.MinPoolSize); + Assert.Equal(300, reopened.MaxPoolSize); + Assert.Equal(200, reopened.MaxAutoPrepare); + } + + /// + /// If the request defaults and the plugin defaults drift apart, the dashboard would save a pool nobody + /// asked for. + /// + [Fact] + public void RequestDefaultsMatchThePluginDefaults() + { + var request = new SaveConfigRequest(); + var config = new PluginConfiguration(); + + Assert.Equal(config.MinPoolSize, request.MinPoolSize); + Assert.Equal(config.MaxPoolSize, request.MaxPoolSize); + Assert.Equal(config.MaxAutoPrepare, request.MaxAutoPrepare); + } +} diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/DatabaseConfigurationTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/DatabaseConfigurationTests.cs similarity index 100% rename from tests/Jellyfin.Database.Providers.Postgres.Tests/DatabaseConfigurationTests.cs rename to Tests/Jellyfin.Database.Providers.Postgres.Tests/DatabaseConfigurationTests.cs diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj b/Tests/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj similarity index 100% rename from tests/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj rename to Tests/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/LoggingTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/LoggingTests.cs similarity index 100% rename from tests/Jellyfin.Database.Providers.Postgres.Tests/LoggingTests.cs rename to Tests/Jellyfin.Database.Providers.Postgres.Tests/LoggingTests.cs diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresIntegrationTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresIntegrationTests.cs similarity index 100% rename from tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresIntegrationTests.cs rename to Tests/Jellyfin.Database.Providers.Postgres.Tests/PostgresIntegrationTests.cs diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/ProviderContractTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/ProviderContractTests.cs similarity index 100% rename from tests/Jellyfin.Database.Providers.Postgres.Tests/ProviderContractTests.cs rename to Tests/Jellyfin.Database.Providers.Postgres.Tests/ProviderContractTests.cs diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs similarity index 100% rename from tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs rename to Tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs similarity index 100% rename from tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs rename to Tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs similarity index 100% rename from tests/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs rename to Tests/Jellyfin.Database.Providers.Postgres.Tests/SqlRewriteTests.cs diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/TestEnvironment.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/TestEnvironment.cs similarity index 100% rename from tests/Jellyfin.Database.Providers.Postgres.Tests/TestEnvironment.cs rename to Tests/Jellyfin.Database.Providers.Postgres.Tests/TestEnvironment.cs diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/TestPaths.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/TestPaths.cs similarity index 100% rename from tests/Jellyfin.Database.Providers.Postgres.Tests/TestPaths.cs rename to Tests/Jellyfin.Database.Providers.Postgres.Tests/TestPaths.cs diff --git a/tests/Jellyfin.Database.Providers.Postgres.Tests/WebPageTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/WebPageTests.cs similarity index 100% rename from tests/Jellyfin.Database.Providers.Postgres.Tests/WebPageTests.cs rename to Tests/Jellyfin.Database.Providers.Postgres.Tests/WebPageTests.cs diff --git a/tools/Generar-ResumenEjecutivo.ps1 b/Tools/Generar-ResumenEjecutivo.ps1 similarity index 95% rename from tools/Generar-ResumenEjecutivo.ps1 rename to Tools/Generar-ResumenEjecutivo.ps1 index e05f157..559c66f 100644 --- a/tools/Generar-ResumenEjecutivo.ps1 +++ b/Tools/Generar-ResumenEjecutivo.ps1 @@ -19,7 +19,7 @@ .EXAMPLE $env:POSTGRES_TEST_CONNECTION = 'Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=...' - ./tools/Generar-ResumenEjecutivo.ps1 + ./Tools/Generar-ResumenEjecutivo.ps1 #> [CmdletBinding()] param( @@ -30,9 +30,9 @@ param( $ErrorActionPreference = 'Stop' $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$testProject = Join-Path $repoRoot 'tests/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj' +$testProject = Join-Path $repoRoot 'Tests/Jellyfin.Database.Providers.Postgres.Tests/Jellyfin.Database.Providers.Postgres.Tests.csproj' $pluginProject = Join-Path $repoRoot 'Jellyfin.Database.Providers.Postgres/Jellyfin.Database.Providers.Postgres.csproj' -$resultsDir = Join-Path $repoRoot 'tests/TestResults' +$resultsDir = Join-Path $repoRoot 'Tests/TestResults' $trxName = 'verificacion.trx' if (-not $OutputPath) { $OutputPath = Join-Path $repoRoot 'docs/VERIFICACION-RESUMEN.md' } @@ -102,7 +102,7 @@ else { $lines = [System.Collections.Generic.List[string]]::new() $lines.Add('# Resumen ejecutivo de verificación') $lines.Add('') -$lines.Add('> Documento generado automáticamente por `tools/Generar-ResumenEjecutivo.ps1` a partir del resultado') +$lines.Add('> Documento generado automáticamente por `Tools/Generar-ResumenEjecutivo.ps1` a partir del resultado') $lines.Add('> real de la suite de pruebas. No se edita a mano.') $lines.Add('') $lines.Add('| Dato | Valor |') @@ -158,11 +158,11 @@ $lines.Add('## Cómo reproducirlo') $lines.Add('') $lines.Add('```bash') $lines.Add('# Sólo verificaciones sin base de datos') -$lines.Add('dotnet test tests/Jellyfin.Database.Providers.Postgres.Tests') +$lines.Add('dotnet test Tests/Jellyfin.Database.Providers.Postgres.Tests') $lines.Add('') $lines.Add('# Verificaciones completas, incluida la integración con PostgreSQL') $lines.Add('$env:POSTGRES_TEST_CONNECTION = "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=***"') -$lines.Add('./tools/Generar-ResumenEjecutivo.ps1') +$lines.Add('./Tools/Generar-ResumenEjecutivo.ps1') $lines.Add('```') $lines.Add('') $lines.Add('Las pruebas de integración crean su propia base de datos desechable y la eliminan al terminar:') diff --git a/docs/JELLYFIN-12.1.md b/docs/JELLYFIN-12.1.md index fb6d990..3e3d67c 100644 --- a/docs/JELLYFIN-12.1.md +++ b/docs/JELLYFIN-12.1.md @@ -53,17 +53,16 @@ la versión anterior no representaba. ```powershell dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release -dotnet run --project tests/CompatibilityChecks -c Release +dotnet test Tests/Jellyfin.Database.Providers.Postgres.Tests -c Release ``` Las comprobaciones sin variables de entorno no abren conexiones PostgreSQL. Para integración, proporcionar una instancia **exclusiva de pruebas** con permiso -para crear bases; el runner crea bases nuevas con prefijo `jellyfin_provider_test_`: +para crear bases; la suite crea bases nuevas con prefijo `jellyfin_provider_test_`: ```powershell $env:POSTGRES_TEST_CONNECTION = 'Host=localhost;Port=55439;Database=postgres;Username=postgres' -$env:POSTGRES_TEST_BACKUPS = '1' # Requiere pg_dump y psql en PATH -dotnet run --project tests/CompatibilityChecks -c Release +dotnet test Tests/Jellyfin.Database.Providers.Postgres.Tests -c Release ``` Las pruebas cubren esquema inicial, actualización con datos, UUID, duplicados y diff --git a/docs/VERIFICACION-RESUMEN.md b/docs/VERIFICACION-RESUMEN.md index 6358f87..e961511 100644 --- a/docs/VERIFICACION-RESUMEN.md +++ b/docs/VERIFICACION-RESUMEN.md @@ -1,11 +1,11 @@ # Resumen ejecutivo de verificación -> Documento generado automáticamente por `tools/Generar-ResumenEjecutivo.ps1` a partir del resultado +> Documento generado automáticamente por `Tools/Generar-ResumenEjecutivo.ps1` a partir del resultado > real de la suite de pruebas. No se edita a mano. | Dato | Valor | | --- | --- | -| Fecha de ejecución | 2026-09-16 02:18:57 -07:00 | +| Fecha de ejecución | 2026-09-16 02:50:33 -07:00 | | Versión del plugin | 3.0.0.0 | | Jellyfin validado | 12.1.0 | | Base de datos de pruebas | Jellyfin | @@ -15,12 +15,13 @@ | Verificaciones | Correctas | Con error | Omitidas | | --- | --- | --- | --- | -| 63 | 63 | 0 | 0 | +| 68 | 68 | 0 | 0 | ## Resultado por área | Área | Verificaciones | Resultado | | --- | --- | --- | +| AdvancedOptionTests | 5 | ✅ 5/5 | | DatabaseConfigurationTests | 2 | ✅ 2/2 | | LoggingTests | 3 | ✅ 3/3 | | PostgresIntegrationTests | 11 | ✅ 11/11 | @@ -34,6 +35,11 @@ | Verificación | Resultado | | --- | --- | +| AdvancedOptionTests.CommandTimeoutIsAlwaysApplied | ✅ | +| AdvancedOptionTests.PoolFallsBackToTheSavedConfiguration | ✅ | +| AdvancedOptionTests.PoolFromTheConnectionStringIsRespected | ✅ | +| AdvancedOptionTests.RequestDefaultsMatchThePluginDefaults | ✅ | +| AdvancedOptionTests.SavedConfigurationSurvivesTheRoundTrip | ✅ | | DatabaseConfigurationTests.EngineSwitchIsDetectedFromDatabaseXml | ✅ | | DatabaseConfigurationTests.WrittenDatabaseXmlRoundTripsThroughJellyfinOptions | ✅ | | LoggingTests.FailuresCarryLevelMessageAndStackTrace | ✅ | @@ -102,11 +108,11 @@ ```bash # Sólo verificaciones sin base de datos -dotnet test tests/Jellyfin.Database.Providers.Postgres.Tests +dotnet test Tests/Jellyfin.Database.Providers.Postgres.Tests # Verificaciones completas, incluida la integración con PostgreSQL $env:POSTGRES_TEST_CONNECTION = "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=***" -./tools/Generar-ResumenEjecutivo.ps1 +./Tools/Generar-ResumenEjecutivo.ps1 ``` Las pruebas de integración crean su propia base de datos desechable y la eliminan al terminar: diff --git a/docs/migracion-10.11-a-12.md b/docs/migracion-10.11-a-12.md new file mode 100644 index 0000000..c55d0bb --- /dev/null +++ b/docs/migracion-10.11-a-12.md @@ -0,0 +1,279 @@ +# Migración de Jellyfin 10.11.x a Jellyfin 12.x con PostgreSQL + +Guía oficial para pasar de **Jellyfin 10.11.x + PostgreSQL Database Provider 2.0.1** a +**Jellyfin 12.1.x + PostgreSQL Database Provider 3.0.0** sin perder biblioteca, +usuarios ni datos de reproducción. + +> Versión en inglés: [`migration-10.11-to-12.en.md`](migration-10.11-to-12.en.md) + +--- + +## 1. Resumen en una tabla + +| Servidor | Plugin | Framework | `targetAbi` | Archivo de base de datos SQLite | +|---|---|---|---|---| +| Jellyfin 10.11.x | **2.0.1** (net9.0) | .NET 9 | `10.11.0.0` | `jellyfin.db` (export del plugin) | +| Jellyfin 12.1.x | **3.0.0** (net10.0) | .NET 10 | `12.1.0.0` | `jellyfin.db` (base viva) + `library.db` (rutinas heredadas) | + +Ruta soportada, en una línea: + +``` +10.11.11 + plugin 2.0.1 → exportar PostgreSQL a SQLite → actualizar el servidor a 12.1 + → instalar plugin 3.0.0 → importar SQLite a PostgreSQL +``` + +El **plugin 3.0.0 no puede leer PostgreSQL creado por la versión 2.x**: la versión 3.0.0 +trabaja contra el modelo de datos y el contrato de proveedores de Jellyfin 12.1. Por eso +el puente entre ambas versiones es un archivo SQLite exportado por el plugin 2.0.1. + +--- + +## 2. Por qué el plugin 2.0.1 es imprescindible + +El proceso de ida y vuelta (PostgreSQL → SQLite → PostgreSQL) depende del **export** +que vive en la pestaña *Migracion* del plugin. Ese export tenía un defecto en 2.0.0.0 +que la versión **2.0.1** corrige: + +- En PostgreSQL existen pares de filas que solo difieren **en el uso de mayúsculas** de + una columna que forma parte de la clave primaria (por ejemplo `UserData.ItemId` + con `ae19…` y `AE19…`). +- Al exportar, esos valores se normalizaban, ambas filas colapsaban en la misma clave y + el export **abortaba** con un error de SQLite del tipo: + + ``` + SQLite Error 19: UNIQUE constraint failed: UserData.ItemId, UserData.UserId, UserData.CustomDataKey + ``` + +- El arreglo (`f91432d`, rama `release/2.0.1`) copia **verbatim** las columnas que forman + la clave primaria y añade un **diagnóstico** que indica tabla, número de fila y valores + de la clave cuando existe una violación de constraint. + +Consecuencia práctica: **quien llegue a Jellyfin 12 sin haber podido exportar desde +10.11 no tiene ruta limpia para llevar sus datos**. Por eso 2.0.1 se publica **antes** +que 3.0.0 (ver [`plan-de-publicacion.md`](plan-de-publicacion.md)). + +--- + +## 3. Diagrama del proceso + +```mermaid +flowchart TD + A["Jellyfin 10.11.x
Datos en PostgreSQL"] --> Z{"¿Plugin 2.0.1 instalado?"} + Z -- "No (2.0.0.0)" --> Z1["Paso 0: actualizar el plugin a 2.0.1
y reiniciar Jellyfin"] + Z1 --> B + Z -- Sí --> B["Paso 1: backup completo
pg_dump + carpeta de datos"] + B --> C["Paso 2: Migracion → Exportar PostgreSQL a SQLite
genera data/jellyfin.db"] + C --> D["Paso 3: Desactivar PostgreSQL
se elimina config/database.xml"] + D --> E["Paso 4: detener Jellyfin 10.11
e instalar Jellyfin 12.1"] + E --> F["Paso 5: instalar el plugin 3.0.0
y arrancar (SQLite activo)"] + F --> G["Paso 6: configurar la conexion
a una base PostgreSQL vacia"] + G --> H["Paso 7: Migracion → Iniciar migracion
SQLite a PostgreSQL"] + H --> I["Paso 8: Activar PostgreSQL y reiniciar
escribe config/database.xml"] + I --> J["Paso 9: verificar Health y limpiar backups"] + J --> K["Listo: Jellyfin 12.1 sobre PostgreSQL"] + + C -. "si el export falla" .-> C1["Revisar Postgres-AAAA-MM-DD.log
el diagnostico indica tabla y fila"] + C1 -.-> C +``` + +--- + +## 4. Paso a paso + +### Paso 0 — Actualizar el plugin a 2.0.1 (solo si vienes de 2.0.0.0) + +1. En el panel de Jellyfin 10.11: **Panel → Complementos → Catálogo → PostgreSQL Database Provider**. +2. Instala la versión **2.0.1** y reinicia Jellyfin. +3. Verifica en **Panel → Registros** que el arranque termina sin errores de base de datos. + +> No actualices el servidor a 12.x antes de este paso: una vez en 12.1 el plugin 2.0.1 +> ya no arranca (está compilado para .NET 9 sobre el contrato de 10.11). + +### Paso 1 — Backup completo + +```bash +# 1.a Volcado de PostgreSQL (ajusta host/usuario/base) +pg_dump --format=custom --file=jellyfin-pre-12.dump "postgresql://Jellyfin@127.0.0.1:5432/Jellyfin" + +# 1.b Copia de la carpeta de datos completa (incluye config, metadata y plugins) +# Windows: %LOCALAPPDATA%\jellyfin Linux: /var/lib/jellyfin +``` + +El plugin también puede generarlo desde **Mantenimiento → Crear backup ahora**. + +### Paso 2 — Exportar PostgreSQL a SQLite + +En el plugin 2.0.1, pestaña **Migracion** → sección **Exportar PostgreSQL → SQLite**: + +1. **Ruta del archivo .db destino** — déjala vacía para usar el valor autodetectado: + `/jellyfin.db` (por ejemplo `%LOCALAPPDATA%\jellyfin\data\jellyfin.db`). +2. Pulsa **Iniciar exportacion a SQLite** y sigue el progreso (tabla actual y filas escritas). +3. Al terminar verás `Export completado: ` y, en el log del plugin, el número de + filas y el tamaño final. + +Durante el export el plugin además: + +- Escribe el esquema SQLite derivado de PostgreSQL y sólo copia las columnas presentes + en ambos motores, para conservar intactas las claves foráneas. +- Crea un `library.db` **mínimo pero válido** (4096 bytes) junto al archivo exportado. + Es necesario porque las rutinas de migración heredadas de Jellyfin 12.1 + (`Jellyfin.Server/Migrations/Routines/*MigrateLibraryDb*.cs`) leen `library.db` y lo + respaldan antes de ejecutarse; si el archivo no existe o está corrupto, el respaldo + falla y **el arranque se aborta**. +- Copia el historial de migraciones de código realmente aplicadas y elimina filas + huérfanas que apuntan a usuarios inexistentes, para evitar fallos de FK al volver a SQLite. + +> ⚠️ No muevas ni borres `library.db` después del export: Jellyfin 12.1 lo necesita como +> archivo válido, aunque no contenga datos de tu biblioteca. + +### Paso 3 — Desactivar PostgreSQL + +En la pestaña **Configuracion**, pulsa **Desactivar / Volver a SQLite** (el texto del +botón es *Revertir a SQLite y reiniciar*). El plugin elimina +`/database.xml` y **Jellyfin arrancará en SQLite** la próxima vez. + +Comprueba que el archivo ya no existe: + +```powershell +Test-Path "$env:LOCALAPPDATA\jellyfin\config\database.xml" # debe devolver False +``` + +### Paso 4 — Instalar Jellyfin 12.1 + +1. Detén Jellyfin 10.11 por completo. +2. Instala Jellyfin **12.1.x** (requiere .NET 10) **manteniendo la misma carpeta de datos**. +3. Arranca una vez: Jellyfin 12.1 abrirá `jellyfin.db` (los datos que exportaste) y + ejecutará sus rutinas heredadas contra `library.db`. + +Si el servidor no arranca, revisa el log del servidor (`log/log_AAAAMMDD.log`) y el log +del plugin (`log/Postgres-AAAA-MM-DD.log`). + +### Paso 5 — Instalar el plugin 3.0.0 + +1. Descarga el ZIP del release **3.0.0.0** y colócalo en + `/plugins/PostgreSQL Database Provider_3.0.0.0/`. +2. Reinicia Jellyfin y confirma en **Panel → Complementos** que aparece la versión 3.0.0.0 + y que el log del plugin muestra el modo activo: + + ``` + [ENGINE] Modo activo: SQLite. Archivo: ...\data\jellyfin.db (NNN,N MB) + ``` + +### Paso 6 — Configurar la conexión + +En la pestaña **Configuracion** del plugin: + +1. Escribe la cadena de conexión a una **base de datos nueva y vacía** + (no reutilices la base de la versión 2.x). +2. Pulsa **Probar conexion**. Debe responder `OK`. +3. Opcional: ajusta **Opciones avanzadas** (tamaño mínimo/máximo del pool, sentencias + preparadas, tiempo de espera) y pulsa **Guardar configuracion**. Los valores se reflejan + en **Current status**. + +### Paso 7 — Importar SQLite a PostgreSQL + +1. Pestaña **Migracion**, sección **Migrar datos de SQLite a PostgreSQL**. +2. Verifica la ruta de `jellyfin.db` (se autodetecta). +3. Deja **Truncar tablas antes de insertar** desactivado en una base vacía; actívalo si + repites la importación sobre datos existentes. +4. Pulsa **Iniciar migracion** y espera al 100 %. +5. Al terminar, el panel habilita **Activar PostgreSQL y reiniciar Jellyfin**. + +### Paso 8 — Activar PostgreSQL + +1. Pulsa **Activar PostgreSQL y reiniciar Jellyfin** y confirma el reinicio. +2. El plugin escribe `/database.xml` apuntando a este proveedor y Jellyfin reinicia + sobre PostgreSQL. +3. Confirma en el log del plugin: + + ``` + [ENGINE] Modo activo: PostgreSQL. Connection: Host=...;Password=***** + ``` + +### Paso 9 — Verificar + +1. **Health**: el semáforo debe quedar en `Ok` (conexión, índices inválidos, bloat, + secuencias y consultas lentas). +2. **Mantenimiento → Actualizar estadisticas**: tamaño total y métricas por tabla. +3. **Mantenimiento → Aplicar optimizaciones**: crea los índices GIN y activa la búsqueda + casi instantánea. +4. Navega por la biblioteca y reproduce algo: la prueba definitiva es que el conteo de + ítems y los datos de reproducción coincidan con el backup del Paso 1. + +--- + +## 5. Dónde vive cada archivo (referencia) + +| Elemento | Ruta | +|---|---| +| Base SQLite viva en Jellyfin 12.1 | `/jellyfin.db` | +| Archivo heredado que leen las rutinas de 12.1 | `/library.db` | +| Selector de motor | `/database.xml` (existe = PostgreSQL) | +| Log del plugin | `/Postgres-AAAA-MM-DD.log` | +| Backups del plugin | `/postgres-backups` | +| Backups de migración | `/postgres-migration-backups` | + +`` es la carpeta `data` de Jellyfin (`%LOCALAPPDATA%\jellyfin\data` en Windows, +`/var/lib/jellyfin/data` en Linux) y `` la carpeta `config`. + +--- + +## 6. Si algo falla + +| Síntoma | Causa más probable | Acción | +|---|---|---| +| El export aborta con `UNIQUE constraint failed` | Vienes de 2.0.0.0 | Paso 0: instala 2.0.1 y repite | +| El export termina pero faltan tablas | La tabla no existe en el `.db` destino | Usa una ruta nueva (el plugin crea el esquema) o revisa el log | +| Jellyfin 12.1 no arranca y el log habla de `library.db` | El archivo heredado falta o está corrupto | Restaura el `library.db` que creó el export (4096 bytes válidos) | +| El plugin 3.0.0 no aparece | Carpeta/versión incorrecta | Comprueba `/plugins/PostgreSQL Database Provider_3.0.0.0/` | +| La importación falla con `FK` | Datos huérfanos en el `.db` origen | Repite con **Truncar tablas antes de insertar** | + +**Rollback:** detén Jellyfin, restaura el volcado del Paso 1 y elimina +`/database.xml`. Jellyfin volverá a SQLite con el `jellyfin.db` exportado. + +--- + +## 7. Ruta alternativa: seguir en PostgreSQL sin pasar por SQLite + +Es posible **no exportar a SQLite** y apuntar directamente el plugin 3.0.0 a la misma base +PostgreSQL que usaba 10.11, porque el esquema es el mismo modelo EF y Jellyfin 12.1 trae +sus propias migraciones. Riesgos a tener en cuenta: + +- El plugin 3.0.0 aplicará las migraciones del modelo de 12.1 sobre una base creada por + el modelo de 10.11; cualquier deriva de esquema previa se propaga. +- No existe un archivo SQLite que sirva de red de seguridad. +- Si algo sale mal, sólo puedes volver por el volcado de PostgreSQL del Paso 1. + +Recomendación: **usa la ruta del export** (secciones 4 de este documento) si quieres una +migración reproducible y con vuelta atrás verificable; reserva esta ruta para instalaciones +donde el export sea inviable y tengas un backup reciente probado. + +--- + +## 8. Preguntas frecuentes + +**¿Puedo saltarme el plugin 2.0.1 si voy directo a Jellyfin 12?** +Sí, sólo con la ruta alternativa de la sección 7 y asumiendo sus riesgos. La ruta +soportada exige exportar desde 2.0.1 **antes** de subir el servidor. + +**¿Necesito el mismo hardware o ruta de datos?** +No, pero es lo más simple. Si cambias de máquina, copia la carpeta de datos completa: el +`jellyfin.db` exportado es portátil. + +**¿Y si mi base 2.x tenía esquema personalizado o plugins de terceros?** +Los datos se exportan tabla por tabla; los objetos exclusivos de PostgreSQL (vistas, +funciones propias) no forman parte de la migración. Revisa el log del export para ver la +lista de tablas copiadas. + +**¿Cuánto tarda?** +Con una biblioteca mediana (decenas de miles de ítems) el export y la importación suelen +terminar en pocos minutos; el cuello de botella es el disco, no la CPU. + +--- + +## 9. Sigue leyendo + +- [Novedades de 3.0.0 frente a 2.0.1](novedades-3.0.0.md) +- [Plan de publicación de 2.0.1 y 3.0.0](plan-de-publicacion.md) +- [Guía técnica de la adaptación a Jellyfin 12.1](JELLYFIN-12.1.md) +- [Resumen de verificación automatizada](VERIFICACION-RESUMEN.md) diff --git a/docs/migration-10.11-to-12.en.md b/docs/migration-10.11-to-12.en.md new file mode 100644 index 0000000..7d17f2b --- /dev/null +++ b/docs/migration-10.11-to-12.en.md @@ -0,0 +1,272 @@ +# Migrating from Jellyfin 10.11.x to Jellyfin 12.x with PostgreSQL + +Official guide to move from **Jellyfin 10.11.x + PostgreSQL Database Provider 2.0.1** to +**Jellyfin 12.1.x + PostgreSQL Database Provider 3.0.0** without losing your library, +users or playback data. + +> Spanish version (primary): [`migracion-10.11-a-12.md`](migracion-10.11-a-12.md) + +--- + +## 1. Summary + +| Server | Plugin | Framework | `targetAbi` | SQLite file | +|---|---|---|---|---| +| Jellyfin 10.11.x | **2.0.1** (net9.0) | .NET 9 | `10.11.0.0` | `jellyfin.db` (plugin export) | +| Jellyfin 12.1.x | **3.0.0** (net10.0) | .NET 10 | `12.1.0.0` | `jellyfin.db` (live DB) + `library.db` (legacy routines) | + +The supported path in one line: + +``` +10.11.11 + plugin 2.0.1 → export PostgreSQL to SQLite → upgrade the server to 12.1 + → install plugin 3.0.0 → import SQLite into PostgreSQL +``` + +**Plugin 3.0.0 cannot read a PostgreSQL database created by the 2.x line**: it targets the +Jellyfin 12.1 data model and provider contract. The bridge between both versions is a +SQLite file exported by plugin 2.0.1. + +--- + +## 2. Why plugin 2.0.1 is mandatory + +The round trip (PostgreSQL → SQLite → PostgreSQL) depends on the **export** feature in the +plugin's *Migration* tab. That export had a defect in 2.0.0.0 that **2.0.1** fixes: + +- PostgreSQL can hold row pairs that differ only **by letter case** in a primary-key + column (for example `UserData.ItemId` as `ae19…` and `AE19…`). +- During export those values were normalised, both rows collapsed into the same key and the + export **aborted** with: + + ``` + SQLite Error 19: UNIQUE constraint failed: UserData.ItemId, UserData.UserId, UserData.CustomDataKey + ``` + +- The fix (`f91432d`, branch `release/2.0.1`) copies primary-key columns **verbatim** and + adds a **diagnostic** reporting table, row number and key values on constraint violations. + +Practical consequence: **anyone reaching Jellyfin 12 without a successful export from 10.11 +has no clean route for their data**. That is why 2.0.1 ships **before** 3.0.0 (see +[`release-plan.en.md`](release-plan.en.md)). + +--- + +## 3. Process diagram + +```mermaid +flowchart TD + A["Jellyfin 10.11.x
Data in PostgreSQL"] --> Z{"Is plugin 2.0.1 installed?"} + Z -- "No (2.0.0.0)" --> Z1["Step 0: upgrade the plugin to 2.0.1
and restart Jellyfin"] + Z1 --> B + Z -- Yes --> B["Step 1: full backup
pg_dump + data folder"] + B --> C["Step 2: Migration → Export PostgreSQL to SQLite
creates data/jellyfin.db"] + C --> D["Step 3: Deactivate PostgreSQL
config/database.xml is removed"] + D --> E["Step 4: stop Jellyfin 10.11
and install Jellyfin 12.1"] + E --> F["Step 5: install plugin 3.0.0
and start (SQLite active)"] + F --> G["Step 6: configure the connection
to an empty PostgreSQL database"] + G --> H["Step 7: Migration → Start migration
SQLite to PostgreSQL"] + H --> I["Step 8: Activate PostgreSQL and restart
writes config/database.xml"] + I --> J["Step 9: verify Health and clean backups"] + J --> K["Done: Jellyfin 12.1 on PostgreSQL"] + + C -. "if the export fails" .-> C1["Check Postgres-YYYY-MM-DD.log
the diagnostic points at table and row"] + C1 -.-> C +``` + +--- + +## 4. Step by step + +### Step 0 — Upgrade the plugin to 2.0.1 (only if you come from 2.0.0.0) + +1. In Jellyfin 10.11: **Dashboard → Plugins → Catalog → PostgreSQL Database Provider**. +2. Install **2.0.1** and restart Jellyfin. +3. Confirm in **Dashboard → Logs** that startup finishes with no database errors. + +> Do not upgrade the server to 12.x first: on 12.1 plugin 2.0.1 will no longer start +> (it is built for .NET 9 against the 10.11 contract). + +### Step 1 — Full backup + +```bash +# 1.a PostgreSQL dump (adjust host/user/database) +pg_dump --format=custom --file=jellyfin-pre-12.dump "postgresql://Jellyfin@127.0.0.1:5432/Jellyfin" + +# 1.b Copy the whole data folder (config, metadata and plugins included) +# Windows: %LOCALAPPDATA%\jellyfin Linux: /var/lib/jellyfin +``` + +The plugin can also produce it from **Maintenance → Create backup now**. + +### Step 2 — Export PostgreSQL to SQLite + +In plugin 2.0.1, **Migration** tab → **Export PostgreSQL → SQLite**: + +1. **Target .db file path** — leave it empty to use the auto-detected default: + `/jellyfin.db` (for example `%LOCALAPPDATA%\jellyfin\data\jellyfin.db`). +2. Press **Start export to SQLite** and follow progress (current table, exported rows). +3. When it finishes you get `Export completed: ` plus row count and final size in the + plugin log. + +During export the plugin also: + +- Writes the SQLite schema derived from PostgreSQL and copies only columns present in both + engines, so foreign keys stay intact. +- Creates a **minimal but valid** `library.db` (4096 bytes) next to the exported file. + Jellyfin 12.1 legacy migration routines + (`Jellyfin.Server/Migrations/Routines/*MigrateLibraryDb*.cs`) read and back up + `library.db` before running; if it is missing or corrupt the backup fails and **startup + aborts**. +- Copies the code-migration history that actually ran and removes orphaned rows pointing to + non-existent users, preventing FK failures when switching back to SQLite. + +> ⚠️ Do not move or delete `library.db` after the export: Jellyfin 12.1 needs it as a valid +> file even though it holds none of your library data. + +### Step 3 — Deactivate PostgreSQL + +In the **Configuration** tab press **Deactivate / Revert to SQLite** (*Revert to SQLite and +restart*). The plugin removes `/database.xml`, so **Jellyfin starts in SQLite** next +time. + +```powershell +Test-Path "$env:LOCALAPPDATA\jellyfin\config\database.xml" # must be False +``` + +### Step 4 — Install Jellyfin 12.1 + +1. Stop Jellyfin 10.11 completely. +2. Install Jellyfin **12.1.x** (requires .NET 10) **keeping the same data folder**. +3. Start it once: Jellyfin 12.1 opens `jellyfin.db` (your exported data) and runs its legacy + routines against `library.db`. + +If the server does not start, check the server log (`log/log_YYYYMMDD.log`) and the plugin +log (`log/Postgres-YYYY-MM-DD.log`). + +### Step 5 — Install plugin 3.0.0 + +1. Download the **3.0.0.0** release ZIP into + `/plugins/PostgreSQL Database Provider_3.0.0.0/`. +2. Restart Jellyfin and confirm in **Dashboard → Plugins** that version 3.0.0.0 is listed and + the plugin log reports the active engine: + + ``` + [ENGINE] Modo activo: SQLite. Archivo: ...\data\jellyfin.db (NNN.N MB) + ``` + +### Step 6 — Configure the connection + +In the plugin's **Configuration** tab: + +1. Enter a connection string to a **new, empty database** (do not reuse the 2.x database). +2. Press **Test connection**; it must answer `OK`. +3. Optional: tune **Advanced options** (minimum/maximum pool size, prepared statements, + command timeout) and press **Save configuration**. Values are reflected in + **Current status**. + +### Step 7 — Import SQLite into PostgreSQL + +1. **Migration** tab → **Migrate data from SQLite to PostgreSQL**. +2. Check the `jellyfin.db` path (auto-detected). +3. Leave **Truncate tables before insert** off on an empty database; enable it when repeating + the import over existing data. +4. Press **Start migration** and wait for 100 %. +5. When done, the panel enables **Activate PostgreSQL and restart Jellyfin**. + +### Step 8 — Activate PostgreSQL + +1. Press **Activate PostgreSQL and restart Jellyfin** and confirm the restart. +2. The plugin writes `/database.xml` pointing at this provider and Jellyfin restarts + on PostgreSQL. +3. Confirm in the plugin log: + + ``` + [ENGINE] Modo activo: PostgreSQL. Connection: Host=...;Password=***** + ``` + +### Step 9 — Verify + +1. **Health**: the traffic light must read `Ok` (connection, invalid indexes, bloat, sequences, + slow queries). +2. **Maintenance → Refresh statistics**: total size and per-table metrics. +3. **Maintenance → Apply optimizations**: creates the GIN indexes and enables near-instant + search. +4. Browse the library and play something: item counts and playback data must match the backup + from Step 1. + +--- + +## 5. Where files live (reference) + +| Item | Path | +|---|---| +| Live SQLite database on Jellyfin 12.1 | `/jellyfin.db` | +| Legacy file read by 12.1 routines | `/library.db` | +| Engine selector | `/database.xml` (present = PostgreSQL) | +| Plugin log | `/Postgres-YYYY-MM-DD.log` | +| Plugin backups | `/postgres-backups` | +| Migration backups | `/postgres-migration-backups` | + +`` is Jellyfin's `data` folder (`%LOCALAPPDATA%\jellyfin\data` on Windows, +`/var/lib/jellyfin/data` on Linux) and `` is the `config` folder. + +--- + +## 6. When something goes wrong + +| Symptom | Most likely cause | Action | +|---|---|---| +| Export aborts with `UNIQUE constraint failed` | You are on 2.0.0.0 | Step 0: install 2.0.1 and retry | +| Export succeeds but tables are missing | Table absent from the target `.db` | Use a new path (the plugin creates the schema) or check the log | +| Jellyfin 12.1 will not start, log mentions `library.db` | Legacy file missing or corrupt | Restore the `library.db` produced by the export (4096 valid bytes) | +| Plugin 3.0.0 is not listed | Wrong folder/version | Check `/plugins/PostgreSQL Database Provider_3.0.0.0/` | +| Import fails with `FK` | Orphaned rows in the source `.db` | Retry with **Truncate tables before insert** | + +**Rollback:** stop Jellyfin, restore the Step 1 dump and delete `/database.xml`. +Jellyfin goes back to SQLite with the exported `jellyfin.db`. + +--- + +## 7. Alternative path: staying on PostgreSQL without SQLite + +You can skip the SQLite export and point plugin 3.0.0 at the same PostgreSQL database 10.11 +used, because the schema is the same EF model and Jellyfin 12.1 ships its own migrations. +Risks: + +- Plugin 3.0.0 will apply the 12.1 model migrations over a database created by the 10.11 + model; any previous schema drift propagates. +- There is no SQLite file acting as a safety net. +- If anything breaks, the only way back is the PostgreSQL dump from Step 1. + +Recommendation: **use the export path** (section 4) for a reproducible migration with a +verifiable rollback; keep this path for installations where the export is not viable and you +hold a recent, tested backup. + +--- + +## 8. FAQ + +**Can I skip plugin 2.0.1 by going straight to Jellyfin 12?** +Only through the alternative path in section 7, accepting its risks. The supported path +requires exporting from 2.0.1 **before** upgrading the server. + +**Do I need the same hardware or data path?** +No, but it is simpler. When moving machines, copy the whole data folder: the exported +`jellyfin.db` is portable. + +**What about custom schemas or third-party plugins?** +Data is exported table by table; PostgreSQL-only objects (views, custom functions) are not +part of the migration. Check the export log for the list of copied tables. + +**How long does it take?** +A mid-sized library (tens of thousands of items) usually finishes export and import in a few +minutes; disk, not CPU, is the bottleneck. + +--- + +## 9. Keep reading + +- [What is new in 3.0.0 vs 2.0.1](whats-new-3.0.0.en.md) +- [Release plan for 2.0.1 and 3.0.0](release-plan.en.md) +- [Technical guide for the Jellyfin 12.1 adaptation](JELLYFIN-12.1.md) +- [Automated verification summary](VERIFICACION-RESUMEN.md) diff --git a/docs/novedades-3.0.0.md b/docs/novedades-3.0.0.md new file mode 100644 index 0000000..d657edd --- /dev/null +++ b/docs/novedades-3.0.0.md @@ -0,0 +1,172 @@ +# Novedades de 3.0.0 frente a 2.0.1 + +Comparativa técnica entre la línea **2.x** (Jellyfin 10.11.x, .NET 9) y **3.0.0** +(Jellyfin 12.1.x, .NET 10). Todos los puntos están tomados del código y de los commits de +la rama `release/V3`. + +> English version: [`whats-new-3.0.0.en.md`](whats-new-3.0.0.en.md) + +--- + +## 1. Resumen + +| Área | 2.0.1 | 3.0.0 | +|---|---|---| +| Jellyfin objetivo | 10.11.x | **12.1.x** | +| Framework | net9.0 | **net10.0** | +| `targetAbi` | `10.11.0.0` | `12.1.0.0` | +| Contrato de proveedor | 10.11 | **12.1 completo** (incluido `PurgeDatabase`) | +| Restauración de respaldo completo de Jellyfin | ❌ rompe el arranque | ✅ soportada | +| Consola SQL en el panel | ❌ | ✅ 10 plantillas + historial | +| Suite de pruebas | runner artesanal | ✅ xUnit, 68 pruebas, resumen autogenerado | +| Opciones avanzadas (pool/timeout) | guardadas pero ignoradas | ✅ persistidas, validadas y visibles | +| Log de errores | mensaje plano | ✅ estructura de `PostgresException` + traza | +| Pestañas del panel | 4 | ✅ 5 (se añade *Consola SQL*) | + +--- + +## 2. Adaptación al contrato y al runtime de Jellyfin 12.1 + +Jellyfin 12.1 cambió la forma en que el servidor descubre, arranca y migra proveedores de +base de datos. La versión 3.0.0 incorpora el trabajo de adaptación completo: + +- **`MigrationAssemblyResolver`** (nuevo): localiza y carga el ensamblado del proveedor + SQLite que Jellyfin 12.1 distribuye junto al servidor, en lugar de asumir que está en el + directorio del plugin. +- **`SqliteCompatibilityBootstrap`** (nuevo): prepara la base SQLite por base de datos (no + por proceso), endurecido para no abortar `Initialise` antes de que Jellyfin tenga logger. +- **`MigrationEngine`, `MigrationTableCopier`, `MigrationSchemaPreparer`, + `MigrationCodeMigrations`, `MigrationService`**: reescritos para respetar el orden de + dependencias, copiar por lotes, truncar antes de la primera fila (evitando cascadas + posteriores), y reportar una tabla fallida como importación fallida en vez de continuar en + silencio. +- **`MigrationOptions`** y `MaintenanceService` alineados con el nuevo ciclo de vida. + +### `PurgeDatabase` ya no rompe el arranque + +En la línea 2.x, `PurgeDatabase` lanzaba `NotImplementedException`. Jellyfin 12.1 lo invoca +al restaurar un **respaldo completo del sistema** (`FullSystemBackup`), por lo que esa +operación dejaba el servidor inservible. En 3.0.0 está implementado con +`TRUNCATE … RESTART IDENTITY CASCADE`, validando y citando correctamente los nombres de +tabla que envía el servidor (incluidas las formas `public.Tabla`, `"public"."Tabla"` y +`Tabla`). + +--- + +## 3. Consola SQL de solo lectura + +Nueva pestaña **Consola SQL** en el panel del plugin: + +- **Solo lectura por diseño**: el servicio valida la sentencia y rechaza mutaciones + (`INSERT`/`UPDATE`/`DELETE`/`DDL`/`TRUNCATE`) y varias sentencias en un solo envío. +- **10 plantillas** listas para diagnosticar: consultas lentas de `pg_stat_statements`, + tamaños por tabla, índices no usados, conexiones activas, bloat, bloqueos, etc. +- Las plantillas filtran por `dbid` y excluyen sentencias administrativas, de modo que no + arrastran ruido de otras bases del mismo servidor PostgreSQL. +- **Historial** de las últimas consultas conservado en `sessionStorage`, con botón para + limpiarlo. +- La sentencia se devuelve **sin comentarios pero sin tocar literales ni identificadores + entrecomillados** (un fallo detectado por las propias pruebas: devolver la versión + enmascarada convertía `"BaseItems"` en `""` y PostgreSQL respondía `42601`). + +--- + +## 4. Verificación automatizada (xUnit) + +Se retiró el runner propio y se sustituyó por una suite estándar, ejecutable con `dotnet test`: + +| Clase | Qué cubre | +|---|---| +| `ProviderContractTests` | El proveedor expone exactamente un proveedor, sin miembros de interfaz sin implementar | +| `DatabaseConfigurationTests` | Detección del motor a partir de `database.xml` | +| `SchemaAuditTests` | Toda tabla/columna usada en SQL crudo existe en el modelo EF | +| `SqlRewriteTests` | Reescritura de SQL de 10.11 → 12.1 idempotente y acotada | +| `QueryConsoleTests` | Acepta solo lectura, rechaza mutaciones y sentencias múltiples | +| `LoggingTests` | El log conserva nivel, mensaje y traza; el detalle de PostgreSQL llega al archivo | +| `WebPageTests` | Integridad del panel: ids, claves i18n simétricas ES/EN | +| `AdvancedOptionTests` | Pool y timeout: la cadena de conexión manda, la configuración rellena huecos | +| `PostgresIntegrationTests` | Integración real: esquema inicial, importación/exportación, purga, respaldos | + +Además: + +- `Tools/Generar-ResumenEjecutivo.ps1` genera + [`VERIFICACION-RESUMEN.md`](VERIFICACION-RESUMEN.md) a partir del `.trx` real, con + clasificación por clase y semáforo por resultado. +- `.github/workflows/verification.yml` ejecuta la suite contra un servicio PostgreSQL 17 y + publica el resumen en la pestaña *Summary* del job. +- `.github/workflows/build.yaml` compila y ejecuta la suite en cada push a `main`/`release/*`. + +Resultado en la rama `release/V3`: **68 pruebas, 0 fallos**. + +--- + +## 5. Logging útil de verdad + +- Todos los mensajes del plugin van a `/Postgres-AAAA-MM-DD.log` (un archivo propio, sin + mezclarse con el log del servidor). +- Los errores de PostgreSQL incluyen ahora los campos estructurados de `PostgresException`: + `SqlState`, `Detail`, `Hint`, tabla, columna, constraint y `WHERE`, cada uno truncado para + no inundar el archivo. +- La traza completa se escribe **una vez por firma** (`SqlState|Constraint|Message`); + las repeticiones se resumen. +- Los comandos SQL implicados se registran (500 caracteres) y, si se activa + **Registrar parámetros de comando**, también sus valores. + +--- + +## 6. Opciones avanzadas que sí funcionan + +En 2.0.1 los campos de pool se guardaban pero el proveedor **sobrescribía** el pool tomado de +la configuración con un valor fijo, y el estado actual no los mostraba. En 3.0.0: + +- `SaveConfig` **valida** (`MaxPoolSize >= MinPoolSize`; si no, responde 400) y **persiste** + `MinPoolSize`, `MaxPoolSize` y `MaxAutoPrepare`. +- El proveedor construye la cadena de conexión con `BuildTunedConnectionString`: **si la + cadena escrita por el usuario trae la clave, esa manda**; si no, se rellena con la + configuración guardada. El tiempo de espera se aplica siempre. +- **Current status** muestra `Pool: min–max · Timeout: N s · Prepared statements: N`, de modo + que el efecto del cambio es visible en la misma pantalla. +- Cambiar el pool requiere reiniciar Jellyfin (el propio panel lo advierte). + +--- + +## 7. Panel más claro + +- Cinco pestañas coherentes: **Configuracion**, **Migracion**, **Mantenimiento**, **Health** + y **Consola SQL**. +- Diccionarios de traducción ES/EN **simétricos** (una prueba automática falla si se añade una + clave a un idioma y no al otro). +- Tablas, botones de acción, avisos y errores con el mismo lenguaje visual; foco visible para + navegación por teclado. +- Historial de consultas y limpieza del mismo desde la propia interfaz. + +--- + +## 8. Exportación a SQLite más robusta + +- **2.0.1**: las columnas que forman la clave primaria se copian verbatim, preservando + mayúsculas/minúsculas, y una violación de constraint se reporta con tabla, número de fila y + valores de la clave. +- **3.0.0** añade sobre eso: copia solo de columnas presentes en ambos motores, limpieza de + filas huérfanas que referencian usuarios inexistentes y creación de un `library.db` mínimo + válido para que las rutinas heredadas de Jellyfin 12.1 no aborten el arranque + (ver [`migracion-10.11-a-12.md`](migracion-10.11-a-12.md)). + +--- + +## 9. Qué NO cambia + +- El `guid` del plugin y el nombre del catálogo son idénticos, así que la actualización desde + la versión 2.x conserva la configuración guardada. +- Las tareas programadas (backup, VACUUM ANALYZE, REINDEX, optimización de índices) mantienen + sus nombres y su programación. +- La integración opcional con JellyTrend sigue disponible a través de + `JellyTrendContract/IRecommendationQueryProvider.cs`. + +--- + +## 10. Sigue leyendo + +- [Manual de migración de 10.11.x a 12.x](migracion-10.11-a-12.md) +- [Plan de publicación de 2.0.1 y 3.0.0](plan-de-publicacion.md) +- [Guía técnica de la adaptación a Jellyfin 12.1](JELLYFIN-12.1.md) diff --git a/docs/plan-de-publicacion.md b/docs/plan-de-publicacion.md new file mode 100644 index 0000000..f5b3ad8 --- /dev/null +++ b/docs/plan-de-publicacion.md @@ -0,0 +1,182 @@ +# Plan de publicación: 2.0.1 y 3.0.0 + +Cómo publicar las dos versiones que sostienen la migración a Jellyfin 12: +**2.0.1** (para Jellyfin 10.11.x) y **3.0.0** (para Jellyfin 12.1.x). + +> English version: [`release-plan.en.md`](release-plan.en.md) + +--- + +## 1. Por qué hay que publicar 2.0.1 antes que 3.0.0 + +El catálogo de Jellyfin **sirve un único `manifest.json`**: el de la rama `main`. Cada +versión del manifiesto declara su `targetAbi`, y el cliente descarga la versión **más alta +compatible** con el servidor del usuario. + +Consecuencias directas: + +1. Un usuario en **Jellyfin 10.11.x** sólo puede ver e instalar entradas con + `targetAbi 10.11.*`. Si 2.0.1 no está en el manifiesto, ese usuario se queda en 2.0.0.0. +2. Ese usuario **necesita 2.0.1** para exportar su PostgreSQL a SQLite sin toparse con el + error de clave primaria por mayúsculas/minúsculas (ver + [`migracion-10.11-a-12.md`](migracion-10.11-a-12.md), sección 2). +3. Sin export no hay puente hacia Jellyfin 12: son **dos releases con propósitos distintos**, + no dos intentos de la misma cosa. + +``` +v2.0.1 → rescata a los usuarios de 10.11 (export fiable a SQLite) +v3.0.0 → habilita Jellyfin 12.1 con el nuevo contrato de proveedores +``` + +--- + +## 2. Qué se corrigió en el flujo de release + +`release.yml` actualizaba el `manifest.json` **de la rama desde la que se lanzaba**. Al +publicar desde `release/2.0.1`, la entrada quedaba en esa rama y **nunca llegaba al catálogo** +(que se lee de `main`): el release existía, pero nadie lo veía. + +Ahora el flujo, tras crear la entrada en la rama de release, **replica la misma entrada en +`main`** mediante un worktree temporal y un commit del bot. La mejora está aplicada en: + +- rama `release/V3` (publica 3.0.0 con `targetAbi 12.1.0.0`), y +- rama `release/2.0.1` (publica 2.0.1 con `targetAbi 10.11.0.0`). + +El paso es idempotente: si `main` ya tiene esa versión, termina sin commit. + +--- + +## 3. Antes de publicar + +```bash +# 1. Ambas ramas compilan sin advertencias +git switch release/2.0.1 && dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release +git switch release/V3 && dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release + +# 2. La suite de 3.0.0 pasa en verde (68 pruebas) +$env:POSTGRES_TEST_CONNECTION = 'Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=***' +./Tools/Generar-ResumenEjecutivo.ps1 +``` + +Comprueba además que `main` y `release/V3` están alineadas en lo que respecta al plugin +(el manifiesto se actualiza solo, no lo edites a mano). + +--- + +## 4. Publicar 2.0.1 (primero) + +**GitHub → Actions → 📦 Release Plugin → Run workflow** + +| Entrada | Valor | +|---|---| +| `release_version` | `2.0.1` | +| `changelog` | ver más abajo | +| `prerelease` | `false` | +| Rama | `release/2.0.1` | + +Changelog sugerido: + +``` +2.0.1 — Fix crítico de exportación PostgreSQL → SQLite + +- Fix: las columnas que forman la clave primaria se copian sin normalizar, por lo que + los pares de filas que solo difieren en mayúsculas/minúsculas ya no colapsan en la + misma clave y el export completo deja de abortar. +- Fix: cuando hay una violación de constraint, el log indica tabla, número de fila y + valores de la clave en lugar del error criptico de SQLite. +- CI: la entrada del catálogo se publica también en main para que Jellyfin 10.11.x + pueda ver esta version. + +Requiere Jellyfin 10.11.x. Para Jellyfin 12.1 usa la version 3.0.0. +``` + +Resultado: tag `v2.0.1`, ZIP `Jellyfin.Database.Providers.Postgres-2.0.1.0.zip`, entrada +`2.0.1.0` con `targetAbi 10.11.0.0` en el manifiesto de `main`. + +--- + +## 5. Publicar 3.0.0 (después) + +**GitHub → Actions → 📦 Release Plugin → Run workflow** + +| Entrada | Valor | +|---|---| +| `release_version` | `3.0.0` | +| `changelog` | ver más abajo | +| `prerelease` | `false` | +| Rama | `release/V3` (o `main` tras fusionarla) | + +Changelog sugerido: + +``` +3.0.0 — Jellyfin 12.1 + +- Nuevo: adaptacion completa al contrato de proveedores de Jellyfin 12.1 (requiere .NET 10). +- Fix critico: PurgeDatabase implementado. En 2.x lanzaba NotImplementedException y rompia + la restauracion del respaldo completo del sistema. +- Nuevo: consola SQL de solo lectura en el panel, con 10 plantillas de diagnostico e historial. +- Nuevo: opciones avanzadas funcionales (tamano de pool, sentencias preparadas, timeout), + validadas y visibles en el estado actual. +- Nuevo: suite de pruebas xUnit (68 pruebas) con resumen ejecutivo autogenerado en CI. +- Mejor: log propio del plugin con detalle estructurado de PostgresException. +- Mejor: exportacion a SQLite mas robusta y arranque seguro en servidores ya migrados. + +Requiere Jellyfin 12.1.x. Para Jellyfin 10.11.x usa la version 2.0.1. +``` + +Resultado: tag `v3.0.0`, ZIP `Jellyfin.Database.Providers.Postgres-3.0.0.0.zip`, entrada +`3.0.0.0` con `targetAbi 12.1.0.0` en el manifiesto de `main`. + +--- + +## 6. Verificación posterior (obligatoria) + +```bash +# 6.a El manifiesto servido contiene las dos entradas y sus ABI +curl -s https://raw.githubusercontent.com/BORNIOS/Jellyfin-Database-Providers-Postgres/main/manifest.json \ + | jq '.[0].versions[] | {version, targetAbi}' + +# Esperado: +# { "version": "2.0.1.0", "targetAbi": "10.11.0.0" } +# { "version": "3.0.0.0", "targetAbi": "12.1.0.0" } + +# 6.b El checksum del manifiesto coincide con el ZIP descargado +curl -sL -o pgp-2.0.1.0.zip https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/download/v2.0.1/Jellyfin.Database.Providers.Postgres-2.0.1.0.zip +md5sum pgp-2.0.1.0.zip # debe coincidir con el campo checksum de la entrada + +# 6.c El ZIP trae los tres DLLs +unzip -l pgp-2.0.1.0.zip # Jellyfin.Database.Providers.Postgres.dll, Npgsql.dll, Npgsql.EntityFrameworkCore.PostgreSQL.dll +``` + +Prueba funcional mínima, en dos instalaciones limpias: + +1. **Jellyfin 10.11.11** → el catálogo ofrece 2.0.1; instálalo y ejecuta un export a SQLite. +2. **Jellyfin 12.1.x** → el catálogo ofrece 3.0.0; instálalo, importa ese SQLite y activa + PostgreSQL. + +--- + +## 7. Riesgos y notas + +- **No reutilices una versión ya publicada**: el flujo sustituye la entrada, pero el tag y el + ZIP del release anterior quedan huérfanos. Si necesitas corregir algo, publica 2.0.2 / 3.0.1. +- **Usa `prerelease: true`** si quieres validar el ZIP antes de exponerlo; el manifiesto se + actualiza igual, así que retíralo del manifiesto si decides no publicarlo. +- **Los ZIP no son intercambiables**: el de 2.0.1 lleva DLLs `net9.0` y el de 3.0.0 DLLs + `net10.0`. +- **El bot necesita permiso de escritura** (`permissions: contents: write`, ya declarado) para + crear el release y empujar el manifiesto. + +## 8. Vuelta atrás + +1. Borra el release y el tag en GitHub. +2. Revierte el commit del bot en `main` que añadió la entrada al manifiesto: + `git revert -- manifest.json` y `git push origin main`. +3. En el servidor, desinstala el plugin y vuelve a la versión anterior. + +--- + +## 9. Sigue leyendo + +- [Manual de migración de 10.11.x a 12.x](migracion-10.11-a-12.md) +- [Novedades de 3.0.0 frente a 2.0.1](novedades-3.0.0.md) diff --git a/docs/release-plan.en.md b/docs/release-plan.en.md new file mode 100644 index 0000000..1ac683b --- /dev/null +++ b/docs/release-plan.en.md @@ -0,0 +1,179 @@ +# Release plan: 2.0.1 and 3.0.0 + +How to publish the two versions that carry the move to Jellyfin 12: +**2.0.1** (for Jellyfin 10.11.x) and **3.0.0** (for Jellyfin 12.1.x). + +> Spanish version (primary): [`plan-de-publicacion.md`](plan-de-publicacion.md) + +--- + +## 1. Why 2.0.1 must ship before 3.0.0 + +Jellyfin's catalog **serves a single `manifest.json`**: the one on `main`. Every manifest entry +declares its `targetAbi`, and the client downloads the **highest compatible** version for the +user's server. + +Direct consequences: + +1. A user on **Jellyfin 10.11.x** can only see and install entries with `targetAbi 10.11.*`. + If 2.0.1 is missing from the manifest, that user stays on 2.0.0.0. +2. That user **needs 2.0.1** to export PostgreSQL to SQLite without hitting the primary-key + letter-case error (see [`migration-10.11-to-12.en.md`](migration-10.11-to-12.en.md), + section 2). +3. Without an export there is no bridge to Jellyfin 12: these are **two releases with different + purposes**, not two attempts at the same thing. + +``` +v2.0.1 → rescues 10.11 users (reliable export to SQLite) +v3.0.0 → enables Jellyfin 12.1 with the new provider contract +``` + +--- + +## 2. What was fixed in the release flow + +`release.yml` used to update the `manifest.json` **of the branch it ran on**. Releasing from +`release/2.0.1` therefore left the entry on that branch and it **never reached the catalog** +(which is read from `main`): the release existed, but nobody could see it. + +The flow now **replicates the same entry into `main`** right after updating the release branch, +using a temporary worktree and a bot commit. The improvement is applied on: + +- branch `release/V3` (publishes 3.0.0 with `targetAbi 12.1.0.0`), and +- branch `release/2.0.1` (publishes 2.0.1 with `targetAbi 10.11.0.0`). + +The step is idempotent: if `main` already carries that version, it exits without committing. + +--- + +## 3. Before releasing + +```bash +# 1. Both branches build with no warnings +git switch release/2.0.1 && dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release +git switch release/V3 && dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release + +# 2. The 3.0.0 suite is green (68 tests) +$env:POSTGRES_TEST_CONNECTION = 'Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=***' +./Tools/Generar-ResumenEjecutivo.ps1 +``` + +Also confirm `main` and `release/V3` are aligned for the plugin (the manifest updates itself; +never edit it by hand). + +--- + +## 4. Publish 2.0.1 (first) + +**GitHub → Actions → 📦 Release Plugin → Run workflow** + +| Input | Value | +|---|---| +| `release_version` | `2.0.1` | +| `changelog` | see below | +| `prerelease` | `false` | +| Branch | `release/2.0.1` | + +Suggested changelog: + +``` +2.0.1 — Critical PostgreSQL -> SQLite export fix + +- Fix: primary-key columns are copied verbatim, so row pairs that differ only by letter + case no longer collapse into the same key and the full export stops aborting. +- Fix: on a constraint violation the log reports table, row number and key values instead + of the cryptic SQLite error. +- CI: the catalog entry is also published to main so Jellyfin 10.11.x can see this version. + +Requires Jellyfin 10.11.x. Use 3.0.0 for Jellyfin 12.1. +``` + +Outcome: tag `v2.0.1`, ZIP `Jellyfin.Database.Providers.Postgres-2.0.1.0.zip`, manifest entry +`2.0.1.0` with `targetAbi 10.11.0.0` on `main`. + +--- + +## 5. Publish 3.0.0 (after) + +**GitHub → Actions → 📦 Release Plugin → Run workflow** + +| Input | Value | +|---|---| +| `release_version` | `3.0.0` | +| `changelog` | see below | +| `prerelease` | `false` | +| Branch | `release/V3` (or `main` once merged) | + +Suggested changelog: + +``` +3.0.0 — Jellyfin 12.1 + +- New: full adaptation to the Jellyfin 12.1 database provider contract (requires .NET 10). +- Critical fix: PurgeDatabase implemented. On 2.x it threw NotImplementedException and broke + restoration of the full system backup. +- New: read-only SQL console in the panel, with 10 diagnostic templates and history. +- New: working advanced options (pool size, prepared statements, timeout), validated and + shown in the current status. +- New: xUnit test suite (68 tests) with an executive summary generated in CI. +- Better: dedicated plugin log with structured PostgresException detail. +- Better: more robust SQLite export and safe startup on already migrated servers. + +Requires Jellyfin 12.1.x. Use 2.0.1 for Jellyfin 10.11.x. +``` + +Outcome: tag `v3.0.0`, ZIP `Jellyfin.Database.Providers.Postgres-3.0.0.0.zip`, manifest entry +`3.0.0.0` with `targetAbi 12.1.0.0` on `main`. + +--- + +## 6. Post-release verification (mandatory) + +```bash +# 6.a The served manifest holds both entries and their ABIs +curl -s https://raw.githubusercontent.com/BORNIOS/Jellyfin-Database-Providers-Postgres/main/manifest.json \ + | jq '.[0].versions[] | {version, targetAbi}' + +# Expected: +# { "version": "2.0.1.0", "targetAbi": "10.11.0.0" } +# { "version": "3.0.0.0", "targetAbi": "12.1.0.0" } + +# 6.b Manifest checksum matches the downloaded ZIP +curl -sL -o pgp-2.0.1.0.zip https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/download/v2.0.1/Jellyfin.Database.Providers.Postgres-2.0.1.0.zip +md5sum pgp-2.0.1.0.zip # must match the entry's checksum field + +# 6.c The ZIP carries the three DLLs +unzip -l pgp-2.0.1.0.zip # Jellyfin.Database.Providers.Postgres.dll, Npgsql.dll, Npgsql.EntityFrameworkCore.PostgreSQL.dll +``` + +Minimum functional test, on two clean installs: + +1. **Jellyfin 10.11.11** → the catalog offers 2.0.1; install it and run an export to SQLite. +2. **Jellyfin 12.1.x** → the catalog offers 3.0.0; install it, import that SQLite file and + activate PostgreSQL. + +--- + +## 7. Risks and notes + +- **Never reuse a published version**: the flow replaces the entry, but the old tag and ZIP are + left orphaned. If you must fix something, publish 2.0.2 / 3.0.1. +- **Use `prerelease: true`** to validate a ZIP before exposing it; the manifest is updated + anyway, so remove the entry if you decide not to publish it. +- **ZIPs are not interchangeable**: 2.0.1 carries `net9.0` DLLs, 3.0.0 carries `net10.0` DLLs. +- **The bot needs write access** (`permissions: contents: write`, already declared) to create + the release and push the manifest. + +## 8. Rollback + +1. Delete the release and the tag on GitHub. +2. Revert the bot commit on `main` that added the manifest entry: + `git revert -- manifest.json` and `git push origin main`. +3. On the server, uninstall the plugin and go back to the previous version. + +--- + +## 9. Keep reading + +- [Migration manual from 10.11.x to 12.x](migration-10.11-to-12.en.md) +- [What is new in 3.0.0 vs 2.0.1](whats-new-3.0.0.en.md) diff --git a/docs/whats-new-3.0.0.en.md b/docs/whats-new-3.0.0.en.md new file mode 100644 index 0000000..2468c69 --- /dev/null +++ b/docs/whats-new-3.0.0.en.md @@ -0,0 +1,166 @@ +# What is new in 3.0.0 vs 2.0.1 + +Technical comparison between the **2.x** line (Jellyfin 10.11.x, .NET 9) and **3.0.0** +(Jellyfin 12.1.x, .NET 10). Every item is taken from the code and the commits on branch +`release/V3`. + +> Spanish version (primary): [`novedades-3.0.0.md`](novedades-3.0.0.md) + +--- + +## 1. Summary + +| Area | 2.0.1 | 3.0.0 | +|---|---|---| +| Target Jellyfin | 10.11.x | **12.1.x** | +| Framework | net9.0 | **net10.0** | +| `targetAbi` | `10.11.0.0` | `12.1.0.0` | +| Provider contract | 10.11 | **full 12.1** (including `PurgeDatabase`) | +| Jellyfin full backup restore | ❌ breaks startup | ✅ supported | +| SQL console in the panel | ❌ | ✅ 10 templates + history | +| Test suite | hand-made runner | ✅ xUnit, 68 tests, generated summary | +| Advanced options (pool/timeout) | saved but ignored | ✅ persisted, validated and visible | +| Error logging | flat message | ✅ `PostgresException` structure + stack trace | +| Panel tabs | 4 | ✅ 5 (adds *SQL console*) | + +--- + +## 2. Jellyfin 12.1 contract and runtime adaptation + +Jellyfin 12.1 changed how the server discovers, starts and migrates database providers. +Version 3.0.0 carries the full adaptation work: + +- **`MigrationAssemblyResolver`** (new): locates and loads the SQLite provider assembly that + Jellyfin 12.1 ships next to the server instead of assuming it sits in the plugin folder. +- **`SqliteCompatibilityBootstrap`** (new): prepares the SQLite database per database (not per + process), hardened so it cannot abort `Initialise` before Jellyfin has a logger. +- **`MigrationEngine`, `MigrationTableCopier`, `MigrationSchemaPreparer`, + `MigrationCodeMigrations`, `MigrationService`**: rewritten to honour dependency order, copy + in batches, truncate before the first row (avoiding later cascade surprises) and report a + failed table as a failed import instead of failing silently. +- **`MigrationOptions`** and `MaintenanceService` aligned with the new lifecycle. + +### `PurgeDatabase` no longer breaks startup + +On the 2.x line `PurgeDatabase` threw `NotImplementedException`. Jellyfin 12.1 calls it when +restoring a **full system backup** (`FullSystemBackup`), so that operation left the server +unusable. In 3.0.0 it is implemented with `TRUNCATE … RESTART IDENTITY CASCADE`, validating +and quoting every table name the server sends (`public.Table`, `"public"."Table"` and +`Table`). + +--- + +## 3. Read-only SQL console + +New **SQL console** tab in the plugin panel: + +- **Read-only by design**: the service validates the statement and rejects mutations + (`INSERT`/`UPDATE`/`DELETE`/DDL/`TRUNCATE`) as well as multiple statements in one submission. +- **10 templates** for diagnostics: slow queries from `pg_stat_statements`, per-table sizes, + unused indexes, active connections, bloat, locks, and more. +- Templates filter by `dbid` and exclude administrative statements, so they carry no noise + from other databases on the same PostgreSQL server. +- **History** of recent queries kept in `sessionStorage`, with a clear button. +- The statement is returned **without comments but with literals and quoted identifiers + untouched** (a bug caught by the suite itself: returning the masked copy turned + `"BaseItems"` into `""` and PostgreSQL answered `42601`). + +--- + +## 4. Automated verification (xUnit) + +The hand-made runner was retired in favour of a standard suite runnable with `dotnet test`: + +| Class | Coverage | +|---|---| +| `ProviderContractTests` | The provider exposes exactly one provider, no unimplemented interface members | +| `DatabaseConfigurationTests` | Engine detection from `database.xml` | +| `SchemaAuditTests` | Every table/column used in raw SQL exists in the EF model | +| `SqlRewriteTests` | 10.11 → 12.1 SQL rewriting is idempotent and scoped | +| `QueryConsoleTests` | Accepts read-only statements, rejects mutations and multi-statement input | +| `LoggingTests` | The log keeps level, message and stack trace; PostgreSQL detail reaches the file | +| `WebPageTests` | Panel integrity: element ids, symmetric ES/EN i18n keys | +| `AdvancedOptionTests` | Pool and timeout: the connection string wins, configuration fills gaps | +| `PostgresIntegrationTests` | Real integration: initial schema, import/export, purge, backups | + +Also: + +- `Tools/Generar-ResumenEjecutivo.ps1` generates + [`VERIFICACION-RESUMEN.md`](VERIFICACION-RESUMEN.md) from the real `.trx`, grouped per class + with a traffic light per result. +- `.github/workflows/verification.yml` runs the suite against a PostgreSQL 17 service and + publishes the summary in the job *Summary* tab. +- `.github/workflows/build.yaml` builds and runs the suite on every push to `main`/`release/*`. + +Result on branch `release/V3`: **68 tests, 0 failures**. + +--- + +## 5. Logging that actually helps + +- All plugin messages go to `/Postgres-YYYY-MM-DD.log` (its own file, never mixed with the + server log). +- PostgreSQL failures now include the structured `PostgresException` fields: `SqlState`, + `Detail`, `Hint`, table, column, constraint and `WHERE`, each truncated to avoid flooding. +- The full stack trace is written **once per signature** (`SqlState|Constraint|Message`); + repeats are summarised. +- The SQL involved is recorded (500 characters) and, when **Log command parameters** is on, + its values too. + +--- + +## 6. Advanced options that work + +In 2.0.1 the pool fields were saved but the provider **overwrote** the pool with a fixed value +and the current status never showed them. In 3.0.0: + +- `SaveConfig` **validates** (`MaxPoolSize >= MinPoolSize`, otherwise HTTP 400) and + **persists** `MinPoolSize`, `MaxPoolSize` and `MaxAutoPrepare`. +- The provider builds the connection string with `BuildTunedConnectionString`: **if the user's + connection string carries the key, it wins**; otherwise the saved configuration fills the + gap. Command timeout is always applied. +- **Current status** shows `Pool: min–max · Timeout: N s · Prepared statements: N`, so the + effect of a change is visible on the same screen. +- Changing the pool requires a Jellyfin restart (the panel warns about it). + +--- + +## 7. Clearer panel + +- Five coherent tabs: **Configuration**, **Migration**, **Maintenance**, **Health** and + **SQL console**. +- **Symmetric** ES/EN translation dictionaries (an automated test fails when a key is added to + one language and not the other). +- Tables, action buttons, notices and errors share one visual language; visible focus for + keyboard navigation. +- Query history with in-UI clearing. + +--- + +## 8. More robust export to SQLite + +- **2.0.1**: primary-key columns are copied verbatim, preserving letter case, and a constraint + violation is reported with table, row number and key values. +- **3.0.0** adds on top: copying only columns present in both engines, removing orphaned rows + that reference non-existent users, and creating a minimal valid `library.db` so Jellyfin + 12.1 legacy routines do not abort startup (see + [`migration-10.11-to-12.en.md`](migration-10.11-to-12.en.md)). + +--- + +## 9. What does not change + +- The plugin `guid` and catalog name are identical, so upgrading from 2.x keeps the saved + configuration. +- Scheduled tasks (backup, VACUUM ANALYZE, REINDEX, index optimization) keep their names and + schedules. +- Optional JellyTrend integration is still available through + `JellyTrendContract/IRecommendationQueryProvider.cs`. + +--- + +## 10. Keep reading + +- [Migration manual from 10.11.x to 12.x](migration-10.11-to-12.en.md) +- [Release plan for 2.0.1 and 3.0.0](release-plan.en.md) +- [Technical guide for the Jellyfin 12.1 adaptation](JELLYFIN-12.1.md) From 7300527bb2a3d3c76bc863955d76cd83ad95d634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 02:54:09 -0700 Subject: [PATCH 10/15] fix(provider): propagar las opciones avanzadas a database.xml Jellyfin lee la cadena de conexion activa de config/database.xml, no de la configuracion del plugin. Hasta ahora, cambiar el pool en el panel actualizaba la configuracion y el estado, pero el runtime seguia abriendo conexiones con la cadena antigua guardada en database.xml. - SaveConfig reescribe database.xml (con la cadena ya ajustada) cuando PostgreSQL esta activo, y lo deja registrado en el log del plugin. - Activate escribe la cadena ajustada en lugar de la cadena cruda del request. Suite de verificacion: 68 pruebas, 0 fallos. --- .../Controllers/PostgresController.cs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs b/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs index 8670eec..9f49033 100644 --- a/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs +++ b/Jellyfin.Database.Providers.Postgres/Controllers/PostgresController.cs @@ -208,6 +208,23 @@ public ActionResult SaveConfig([FromBody] SaveConfigRequest request) plugin.SaveConfiguration(); + // When PostgreSQL is already the active engine, Jellyfin reads the connection string from + // database.xml instead of the plugin configuration. Refresh that file so an edit in the + // advanced options reaches the provider on the next restart and does not stay as a value + // the panel shows but the runtime ignores. + if (PostgresPlugin.IsPostgresActive(_appPaths)) + { + var tuned = PostgresDatabaseProvider.BuildTunedConnectionString( + plugin.Configuration.ConnectionString, + plugin.Configuration, + plugin.Configuration.CommandTimeout).ToString(); + + PostgresPlugin.WriteDatabaseXml(_appPaths, tuned, plugin.Configuration.CommandTimeout); + Logging.PostgresLog.Info( + "[ENGINE SWITCH] database.xml actualizado con la configuracion guardada " + + "(pool y timeout incluidos). Reinicia Jellyfin para aplicarla."); + } + return Ok(new { Success = true }); } @@ -329,12 +346,21 @@ public ActionResult Activate([FromBody] ActivateRequest request) return BadRequest(new { Error = "ConnectionString is required." }); } + var plugin = PostgresPlugin.Instance; + + // The active connection string must carry the advanced options: Jellyfin reads it from + // database.xml, and anything missing there falls back to Npgsql defaults. + var activationConnStr = PostgresDatabaseProvider.BuildTunedConnectionString( + request.ConnectionString, + plugin?.Configuration, + request.CommandTimeout).ToString(); + _logger.LogInformation( "Activating PostgreSQL provider. Writing database.xml and scheduling restart..."); try { - PostgresPlugin.WriteDatabaseXml(_appPaths, request.ConnectionString, request.CommandTimeout); + PostgresPlugin.WriteDatabaseXml(_appPaths, activationConnStr, request.CommandTimeout); } catch (Exception ex) { @@ -345,7 +371,6 @@ public ActionResult Activate([FromBody] ActivateRequest request) } // Update plugin config to reflect active state - var plugin = PostgresPlugin.Instance; if (plugin is not null) { plugin.Configuration.ConnectionString = request.ConnectionString; From 079be1c5d29ba54912295bc13b47586a8509fc3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 02:55:24 -0700 Subject: [PATCH 11/15] docs: regenerar el resumen de verificacion (68 pruebas, 0 fallos) --- docs/VERIFICACION-RESUMEN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/VERIFICACION-RESUMEN.md b/docs/VERIFICACION-RESUMEN.md index e961511..2e5dc97 100644 --- a/docs/VERIFICACION-RESUMEN.md +++ b/docs/VERIFICACION-RESUMEN.md @@ -5,7 +5,7 @@ | Dato | Valor | | --- | --- | -| Fecha de ejecución | 2026-09-16 02:50:33 -07:00 | +| Fecha de ejecución | 2026-09-16 02:55:18 -07:00 | | Versión del plugin | 3.0.0.0 | | Jellyfin validado | 12.1.0 | | Base de datos de pruebas | Jellyfin | From 85f69e040b32608d78f93046ba98443f6e05db37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 11:12:55 -0700 Subject: [PATCH 12/15] docs: reordenar el README por pestanas y corregir el manual de migracion - README (ES/EN) reorganizado siguiendo el orden real de pestanas: Configuracion, Migracion, Mantenimiento, Health y Consola SQL, cada una con su descripcion y sus funciones. Se documenta la pestana Consola SQL con Screenshots/Tab-Console.png. - Manual 10.11 -> 12: el paso 3 aclara que Jellyfin recrea database.xml declarando Jellyfin-SQLite al arrancar (lo que no debe aparecer es PLUGIN_PROVIDER). - Paso 5 con las dos vias de instalacion del plugin: catalogo y ZIP manual. - Pasos 6 y 7: reutilizar la base PostgreSQL de 10.11.x es valido; se explica el uso de Truncar tablas antes de insertar y la limpieza de residuos de pruebas. - README ES: se anaden las secciones Compatibilidad e Instalacion que solo tenia la version inglesa, y se actualiza la compatibilidad a Jellyfin 12.1 / .NET 10. - El plan de publicacion se retira del repositorio por ser documentacion interna. --- README.en.md | 122 +++++++++++++++------ README.md | 162 ++++++++++++++++++++------- Screenshots/Tab-Console.png | Bin 0 -> 93753 bytes docs/migracion-10.11-a-12.md | 76 ++++++++++--- docs/migration-10.11-to-12.en.md | 73 ++++++++++--- docs/novedades-3.0.0.md | 1 - docs/plan-de-publicacion.md | 182 ------------------------------- docs/release-plan.en.md | 179 ------------------------------ docs/whats-new-3.0.0.en.md | 1 - 9 files changed, 325 insertions(+), 471 deletions(-) create mode 100644 Screenshots/Tab-Console.png delete mode 100644 docs/plan-de-publicacion.md delete mode 100644 docs/release-plan.en.md diff --git a/README.en.md b/README.en.md index 260c328..409765d 100644 --- a/README.en.md +++ b/README.en.md @@ -36,7 +36,6 @@ automatic health check, scheduled maintenance and optional JellyTrend integratio |---|---| | [Migration 10.11.x → 12.x](docs/migration-10.11-to-12.en.md) · [ES](docs/migracion-10.11-a-12.md) | Step-by-step manual with diagram and contingency plan | | [What is new in 3.0.0](docs/whats-new-3.0.0.en.md) · [ES](docs/novedades-3.0.0.md) | Every change in 3.0.0 vs 2.0.1 | -| [Release plan](docs/release-plan.en.md) · [ES](docs/plan-de-publicacion.md) | Order and runbook to publish 2.0.1 and 3.0.0 | | [Jellyfin 12.1 adaptation](docs/JELLYFIN-12.1.md) | Technical notes and how to run the test suite | | [Automated verification](docs/VERIFICACION-RESUMEN.md) | Executive summary generated from the real suite | @@ -59,11 +58,12 @@ automatic health check, scheduled maintenance and optional JellyTrend integratio | Component | Version | |---|---| -| Jellyfin | **10.11.10** | -| PostgreSQL | **13 +** (recommended 16 / 17) | -| .NET | 9.0 | +| Jellyfin | **12.1.x** — for Jellyfin 10.11.x use plugin **2.0.1** | +| PostgreSQL | **16 +** (17 recommended) | +| .NET | **10.0** | -> ℹ️ The plugin uses `IJellyfinDatabaseProvider` — the same interface as SQLite. No Jellyfin source modifications required. +> ℹ️ The plugin implements `IJellyfinDatabaseProvider`, the same contract as the SQLite provider +> shipped with Jellyfin 12.1. No Jellyfin source modifications required. --- @@ -95,49 +95,53 @@ automatic health check, scheduled maintenance and optional JellyTrend integratio ## 🖥️ Plugin tabs -### ⚙️ Configuration +The plugin adds its own page under **Dashboard → Plugins → PostgreSQL Database Provider** with +**five tabs**, in this order: **Configuration**, **Migration**, **Maintenance**, **Health** and +**SQL console**. Each one covers a part of the database lifecycle. -Manage connection, connection pool and external tool paths. +### ⚙️ Configuration — connection, pool and active engine + +The entry point: which PostgreSQL you connect to, with which pool settings, and which engine +Jellyfin is using right now (SQLite or PostgreSQL). ![Configuration Tab](Screenshots/Tab-Configurations.png) +**Current status** — live summary of what the plugin is really using: active engine, detected SQLite +file, schema, plus a line with `Pool: min-max · Timeout · Prepared statements`. + +**PostgreSQL connection** + | Parameter | Description | Default | |---|---|---| | Connection string | Full Npgsql connection string | — | | Schema | PostgreSQL schema | `public` | -| Command timeout | Max EF Core query time (seconds) | `60` | -| Min pool size | Minimum connections kept alive | `4` | -| Max pool size | Maximum connections in pool | `100` | -| Max auto-prepare | Server-side prepared statements (0 = off) | `50` | | PgBin path | Path to `pg_dump` / `psql` / `pg_restore` | auto-detect | -| Backup directory | Folder for backup files | — | +| Backup directory | Folder for backup files | `/postgres-backups` | | Backup compression | Compress backup as ZIP | `true` | -**Recommended flow:** -1. Fill in connection fields and click **Test connection** — returns server version on success. -2. Adjust pool and timeout for your workload. -3. Click **Save configuration**. -4. After migrating data, click **Activate PostgreSQL** and restart. +**Advanced options** ---- - -### 🩺 Health Check +| Parameter | Description | Default | +|---|---|---| +| Min pool size | Minimum connections Npgsql keeps open | `4` | +| Max pool size | Maximum connections in the pool | `100` | +| Max auto-prepare | Server-side prepared statements (0 = off) | `50` | +| Command timeout | Max EF Core query time (seconds) | `600` | -Automatic database diagnostics. Runs **10 seconds after startup** and can be triggered manually from the UI. +> ℹ️ If your connection string already carries one of these keys, **that value wins** and the +> configuration only fills the gaps. Pool changes require a **Jellyfin restart** to take effect. -![Health Check Tab](Screenshots/Tab-Health.png) +**Actions** -| Check | Description | +| Button | Function | |---|---| -| **Connection** | Verifies PostgreSQL is reachable | -| **Extensions** | `pg_trgm`, `pg_stat_statements` available | -| **Invalid indexes** | Detects and can auto-repair | -| **Table bloat** | Tables with > 20 % dead tuples | -| **Drifted sequences** | Sequences out of range vs actual data | -| **Stale statistics** | Tables without recent `ANALYZE` | -| **Slow queries** | Top queries by cumulative time (`pg_stat_statements`) | +| **Test connection** | Validates the connection string and returns the PostgreSQL server version | +| **Save configuration** | Persists connection, advanced options and paths (rejects `max < min`) | +| **Activate PostgreSQL and restart** | Writes `config/database.xml` in `PLUGIN_PROVIDER` mode and restarts the server | +| **Revert to SQLite and restart** | Removes `config/database.xml` and switches back to SQLite | -Results use a traffic-light system: `Info` / `Warn` / `Error`. Repairable issues can be fixed in one click from the same card. +**Recommended flow:** 1) **Test connection** → 2) tune pool and timeout → 3) **Save +configuration** → 4) migrate data → 5) **Activate PostgreSQL**. --- @@ -177,14 +181,60 @@ Maintenance operations, backups and database statistics. ![Maintenance Tab](Screenshots/Tab-Maintenance.png) -| Action | Description | +| Action | Function | |---|---| | **VACUUM ANALYZE** | Free space and update planner statistics | | **REINDEX DATABASE** | Rebuild all indexes | -| **Apply optimizations** | Create 9 GIN indexes CONCURRENTLY + autovacuum tuning | -| **Create backup now** | Generate `.sql` (and optional `.zip`) with `pg_dump` | -| **Restore backup** | Restore from `.sql` or `.zip` | -| **Update statistics** | Show total size, active connections and per-table metrics | +| **Apply optimizations** | Create 9 GIN indexes CONCURRENTLY and tune autovacuum on critical tables | +| **Create backup now** | Generate a `.sql` with `pg_dump` (plus `.zip` when compression is on) | +| **Restore backup** | Restore from `.sql` or `.zip`, with a picker of available backups | +| **Update statistics** | Total size, active connections and per-table metrics | + +> 💡 **Apply optimizations** also enables near-instant search when `pg_trgm` is available, and it +> is the step that creates the GIN indexes used by the search endpoint. + +--- + +### 🩺 Health — automatic diagnostics + +Checks database health **10 seconds after startup** and lets you re-run the check by hand from the +tab itself. + +![Health Tab](Screenshots/Tab-Health.png) + +| Check | What it verifies | +|---|---| +| **Connection** | That PostgreSQL answers, and with what latency | +| **Extensions** | That `pg_trgm` and `pg_stat_statements` are available | +| **Invalid indexes** | Indexes in `invalid` state, repairable in one click | +| **Table bloat** | Tables with more than 20 % dead tuples | +| **Drifted sequences** | Sequences out of range vs actual data | +| **Stale statistics** | Tables without recent `ANALYZE` | +| **Slow queries** | Top queries by cumulative time (`pg_stat_statements`) | + +Every result carries a traffic light `Info` / `Warn` / `Error`, and repairable issues are fixed from +the same card. The startup summary also lands in the plugin log: + +``` +[HealthCheck] Resumen arranque: 0 error(es), 0 advertencia(s), 65 informativo(s) | Severidad: Ok +``` + +--- + +### 🧪 SQL console — read-only queries + +Diagnostic tool to run `SELECT` statements against the plugin database without leaving the panel or +opening a `psql` session. + +![SQL Console Tab](Screenshots/Tab-Console.png) + +- **Read-only by design**: rejects `INSERT` / `UPDATE` / `DELETE` / DDL and also several statements + submitted at once. +- **10 ready-made templates**: slow queries, per-table size, unused indexes, active connections, + bloat, locks, and more. +- Templates filter by database and exclude administrative statements, so they carry no noise from + other databases on the same PostgreSQL server. +- **History** of recent queries kept in the browser session, with a button to clear it. --- diff --git a/README.md b/README.md index b73d0b3..371d678 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,6 @@ health check automático, mantenimiento programado e integración opcional con J |---|---| | [Migración 10.11.x → 12.x](docs/migracion-10.11-a-12.md) · [EN](docs/migration-10.11-to-12.en.md) | Manual paso a paso, con diagrama y plan de contingencia | | [Novedades de 3.0.0](docs/novedades-3.0.0.md) · [EN](docs/whats-new-3.0.0.en.md) | Qué cambia en 3.0.0 frente a 2.0.1 | -| [Plan de publicación](docs/plan-de-publicacion.md) · [EN](docs/release-plan.en.md) | Orden y runbook para publicar 2.0.1 y 3.0.0 | | [Adaptación a Jellyfin 12.1](docs/JELLYFIN-12.1.md) | Notas técnicas y cómo ejecutar la suite de pruebas | | [Verificación automatizada](docs/VERIFICACION-RESUMEN.md) | Resumen ejecutivo generado desde la suite real | @@ -57,55 +56,94 @@ health check automático, mantenimiento programado e integración opcional con J --- +## ⚙️ Compatibilidad + +| Componente | Versión | +|---|---| +| Jellyfin | **12.1.x** — para Jellyfin 10.11.x usa el plugin **2.0.1** | +| PostgreSQL | **16 +** (recomendado 17) | +| .NET | **10.0** | + +> ℹ️ El plugin implementa `IJellyfinDatabaseProvider`, el mismo contrato que usa el proveedor SQLite +> incluido en Jellyfin 12.1. No requiere modificar el núcleo de Jellyfin. + +--- + +## 🚀 Instalación + +### Opción A — repositorio de plugins (recomendada) + +1. En Jellyfin entra en **Panel → Avanzado → Repositorios de complementos**. +2. Pulsa **Añadir repositorio** y usa esta URL: + + ``` + https://raw.githubusercontent.com/BORNIOS/Jellyfin-Database-Providers-Postgres/main/manifest.json + ``` + +3. Guarda, ve al **Catálogo**, busca **PostgreSQL Database Provider** e **Instala**. +4. Acepta el reinicio cuando te lo pida. + +### Opción B — ZIP manual + +1. Descarga el ZIP del [**Release**](https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/latest). +2. Descomprímelo en la carpeta de plugins de Jellyfin. +3. Reinicia Jellyfin. + +> 💡 Ubicaciones habituales de la carpeta de plugins: +> - **Linux / Docker:** `/config/plugins/` +> - **Windows:** `%LOCALAPPDATA%\jellyfin\plugins\` + +--- + ## 🖥️ Interfaz del plugin -El plugin añade una página de configuración con **cuatro pestañas**: +El plugin añade una página propia en **Panel → Complementos → PostgreSQL Database Provider** con +**cinco pestañas**, en este orden: **Configuración**, **Migración**, **Mantenimiento**, **Health** y +**Consola SQL**. Cada una agrupa una parte del ciclo de vida de la base de datos. -### ⚙️ Configuración +### ⚙️ Configuración — conexión, pool y motor activo -Gestiona la conexión, el pool de conexiones y las rutas de herramientas externas. +Es el punto de entrada: define a qué PostgreSQL te conectas, con qué parámetros de pool y qué motor +usa Jellyfin ahora mismo (SQLite o PostgreSQL). ![Tab Configuración](Screenshots/Tab-Configurations.png) +**Estado actual** — resumen en vivo de lo que el plugin está usando de verdad: motor activo, +archivo SQLite detectado, esquema y una línea con `Pool: min-max · Timeout · Prepared statements`. + +**Conexión PostgreSQL** + | Parámetro | Descripción | Default | |---|---|---| | Connection string | Cadena completa de Npgsql | — | | Schema | Schema PostgreSQL a usar | `public` | -| Command timeout | Tiempo máximo de una query EF Core (segundos) | `60` | -| Min pool size | Conexiones mínimas activas en el pool | `4` | -| Max pool size | Conexiones máximas en el pool | `100` | -| Max auto-prepare | Statements preparados en servidor (0 = desactivado) | `50` | | PgBin path | Ruta a `pg_dump` / `psql` / `pg_restore` | auto-detect | -| Backup directory | Carpeta donde se guardan los backups | — | -| Backup compression | Comprimir backup en ZIP | `true` | - -**Flujo de configuración recomendado:** -1. Rellena los campos de conexión y pulsa **Probar conexión** — devuelve la versión del servidor si todo es correcto. -2. Ajusta pool y timeout según tu carga. -3. Pulsa **Guardar configuración**. -4. Una vez migrados los datos, pulsa **Activar PostgreSQL** y reinicia. - ---- +| Backup directory | Carpeta donde se guardan los backups | `/postgres-backups` | +| Backup compression | Comprimir el backup en ZIP | `true` | -### 🩺 Health Check +**Opciones avanzadas** -Diagnóstico automático de la base de datos. Se ejecuta **10 segundos después del arranque** y puede lanzarse manualmente desde la UI. +| Parámetro | Descripción | Default | +|---|---|---| +| Min pool size | Conexiones mínimas que mantiene abiertas Npgsql | `4` | +| Max pool size | Conexiones máximas del pool | `100` | +| Max auto-prepare | Sentencias preparadas en el servidor (0 = desactivado) | `50` | +| Command timeout | Tiempo máximo de una query EF Core (segundos) | `600` | -![Tab Health Check](Screenshots/Tab-Health.png) +> ℹ️ Si tu cadena de conexión ya incluye una de estas claves, **ese valor manda** y la configuración +> solo rellena lo que falte. Los cambios de pool requieren **reiniciar Jellyfin** para aplicarse. -Comprueba y reporta: +**Acciones** -| Check | Descripción | +| Botón | Función | |---|---| -| **Conexión** | Verifica que PostgreSQL es accesible | -| **Extensiones** | `pg_trgm`, `pg_stat_statements` disponibles | -| **Índices inválidos** | Detecta y puede auto-reparar | -| **Bloat de tablas** | Tablas con > 20 % de tuplas muertas | -| **Secuencias desfasadas** | Secuencias fuera de rango respecto a los datos | -| **Estadísticas obsoletas** | Tablas sin `ANALYZE` reciente | -| **Queries lentas** | Top queries por tiempo acumulado (vía `pg_stat_statements`) | +| **Probar conexion** | Valida la cadena y devuelve la versión del servidor PostgreSQL | +| **Guardar configuracion** | Persiste conexión, opciones avanzadas y rutas (rechaza `max < min`) | +| **Activar PostgreSQL y reiniciar** | Escribe `config/database.xml` en modo `PLUGIN_PROVIDER` y reinicia el servidor | +| **Revertir a SQLite y reiniciar** | Elimina `config/database.xml` y vuelve a SQLite | -Los resultados se clasifican con semáforo: `Info` / `Warn` / `Error`. Los problemas marcados como reparables pueden corregirse en un clic desde la misma card. +**Flujo recomendado:** 1) **Probar conexión** → 2) ajustar pool y timeout → 3) **Guardar +configuración** → 4) migrar los datos → 5) **Activar PostgreSQL**. --- @@ -145,19 +183,61 @@ Operaciones de mantenimiento, backups y estadísticas de la base de datos. ![Tab Mantenimiento](Screenshots/Tab-Maintenance.png) -| Acción | Descripción | +| Acción | Función | |---|---| | **VACUUM ANALYZE** | Libera espacio y actualiza estadísticas del planificador | | **REINDEX DATABASE** | Reconstruye todos los índices | -| **Aplicar optimizaciones** | Crea 9 índices GIN CONCURRENTLY + tuning de autovacuum | -| **Crear backup ahora** | Genera `.sql` (y `.zip` si está activado) con `pg_dump` | -| **Restablecer backup** | Restaura desde `.sql` o `.zip` | -| **Actualizar estadísticas** | Muestra tamaño total, conexiones activas y métricas por tabla | - -> 💡 El botón **Aplicar optimizaciones** activa también la búsqueda casi instantánea si `pg_trgm` está disponible. -| **Crear backup ahora** | Genera `.sql` (y `.zip` opcional) con `pg_dump` | -| **Restablecer backup** | Restaura desde `.sql` o `.zip` | -| **Actualizar estadísticas** | Tamaño total, conexiones activas y métricas por tabla | +| **Aplicar optimizaciones** | Crea 9 índices GIN CONCURRENTLY y ajusta autovacuum en las tablas críticas | +| **Crear backup ahora** | Genera un `.sql` con `pg_dump` (y `.zip` si la compresión está activa) | +| **Restablecer backup** | Restaura desde `.sql` o `.zip`, con selector de backups disponibles | +| **Actualizar estadisticas** | Tamaño total, conexiones activas y métricas tabla por tabla | + +> 💡 **Aplicar optimizaciones** activa también la búsqueda casi instantánea cuando `pg_trgm` está +> disponible, y es el paso que crea los índices GIN que usa el endpoint de búsqueda. + +--- + +### 🩺 Health — diagnóstico automático + +Comprueba la salud de la base **10 segundos después del arranque** y permite relanzar el chequeo a +mano desde la propia pestaña. + +![Tab Health](Screenshots/Tab-Health.png) + +| Check | Qué comprueba | +|---|---| +| **Conexión** | Que PostgreSQL responde, y con qué latencia | +| **Extensiones** | Que `pg_trgm` y `pg_stat_statements` están disponibles | +| **Índices inválidos** | Índices en estado `invalid`, con reparación en un clic | +| **Bloat de tablas** | Tablas con más de 20 % de tuplas muertas | +| **Secuencias desfasadas** | Secuencias fuera de rango respecto a los datos | +| **Estadísticas obsoletas** | Tablas sin `ANALYZE` reciente | +| **Queries lentas** | Top consultas por tiempo acumulado (vía `pg_stat_statements`) | + +Cada resultado lleva semáforo `Info` / `Warn` / `Error`, y los problemas marcados como reparables se +corrigen desde la misma card. El resumen del arranque queda además en el log del plugin: + +``` +[HealthCheck] Resumen arranque: 0 error(es), 0 advertencia(s), 65 informativo(s) | Severidad: Ok +``` + +--- + +### 🧪 Consola SQL — consultas de solo lectura + +Herramienta de diagnóstico para lanzar `SELECT` contra la base del plugin sin salir del panel ni +abrir una sesión `psql`. + +![Tab Consola SQL](Screenshots/Tab-Console.png) + +- **Solo lectura por diseño**: rechaza `INSERT` / `UPDATE` / `DELETE` / DDL y también varias + sentencias enviadas a la vez. +- **10 plantillas** listas para usar: consultas lentas, tamaño por tabla, índices sin uso, + conexiones activas, bloat, bloqueos, y más. +- Las plantillas filtran por base de datos y excluyen sentencias administrativas, así que no + arrastran ruido de otras bases del mismo servidor PostgreSQL. +- **Historial** de las últimas consultas conservado en la sesión del navegador, con botón para + limpiarlo. --- diff --git a/Screenshots/Tab-Console.png b/Screenshots/Tab-Console.png new file mode 100644 index 0000000000000000000000000000000000000000..743fa9f53baa421c038540c429efa40d32faaf19 GIT binary patch literal 93753 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 z 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 b{YwJNPnj*%cYn#2UTJ&W(fawZ%m4a6^_un} literal 0 HcmV?d00001 diff --git a/docs/migracion-10.11-a-12.md b/docs/migracion-10.11-a-12.md index c55d0bb..96976c3 100644 --- a/docs/migracion-10.11-a-12.md +++ b/docs/migracion-10.11-a-12.md @@ -50,7 +50,7 @@ que la versión **2.0.1** corrige: Consecuencia práctica: **quien llegue a Jellyfin 12 sin haber podido exportar desde 10.11 no tiene ruta limpia para llevar sus datos**. Por eso 2.0.1 se publica **antes** -que 3.0.0 (ver [`plan-de-publicacion.md`](plan-de-publicacion.md)). +que 3.0.0: sin export no hay puente entre ambos servidores. --- @@ -129,15 +129,32 @@ Durante el export el plugin además: ### Paso 3 — Desactivar PostgreSQL En la pestaña **Configuracion**, pulsa **Desactivar / Volver a SQLite** (el texto del -botón es *Revertir a SQLite y reiniciar*). El plugin elimina -`/database.xml` y **Jellyfin arrancará en SQLite** la próxima vez. +botón es *Revertir a SQLite y reiniciar*). El plugin elimina `/database.xml`, de +modo que la próxima vez **Jellyfin arranca en SQLite**. -Comprueba que el archivo ya no existe: +Comprueba, justo después de pulsar el botón, que el archivo ya no está: ```powershell Test-Path "$env:LOCALAPPDATA\jellyfin\config\database.xml" # debe devolver False ``` +> ℹ️ Al arrancar, Jellyfin **vuelve a crear** `config/database.xml` declarando SQLite. Es +> normal y es la confirmación de que el motor activo es el correcto: +> +> ```xml +> +> +> Jellyfin-SQLite +> NoLock +> +> ``` + +Lo que **no** debe aparecer en ese archivo es `PLUGIN_PROVIDER`: + +```powershell +Select-String -Path "$env:LOCALAPPDATA\jellyfin\config\database.xml" -Pattern 'PLUGIN_PROVIDER' # sin resultados +``` + ### Paso 4 — Instalar Jellyfin 12.1 1. Detén Jellyfin 10.11 por completo. @@ -150,10 +167,24 @@ del plugin (`log/Postgres-AAAA-MM-DD.log`). ### Paso 5 — Instalar el plugin 3.0.0 -1. Descarga el ZIP del release **3.0.0.0** y colócalo en - `/plugins/PostgreSQL Database Provider_3.0.0.0/`. -2. Reinicia Jellyfin y confirma en **Panel → Complementos** que aparece la versión 3.0.0.0 - y que el log del plugin muestra el modo activo: +Tienes dos caminos; el primero es el más cómodo: + +**Opción A — desde el catálogo (recomendada)** + +1. En Jellyfin 12.1 abre **Panel → Complementos → Catálogo**. +2. Busca **PostgreSQL Database Provider** e instala la versión **3.0.0.0**. +3. Acepta el reinicio que propone Jellyfin. + +**Opción B — instalación manual** + +1. Descarga el ZIP `Jellyfin.Database.Providers.Postgres-3.0.0.0.zip` del release **v3.0.0**. +2. Crea la carpeta `/plugins/PostgreSQL Database Provider_3.0.0.0/` y descomprime + dentro sus tres DLL: `Jellyfin.Database.Providers.Postgres.dll`, `Npgsql.dll` y + `Npgsql.EntityFrameworkCore.PostgreSQL.dll`. +3. Reinicia Jellyfin. + +En cualquiera de los dos casos, confirma en **Panel → Complementos** que aparece la versión +3.0.0.0 y que el log del plugin muestra el modo activo: ``` [ENGINE] Modo activo: SQLite. Archivo: ...\data\jellyfin.db (NNN,N MB) @@ -161,21 +192,33 @@ del plugin (`log/Postgres-AAAA-MM-DD.log`). ### Paso 6 — Configurar la conexión -En la pestaña **Configuracion** del plugin: +En la pestaña **Configuracion** del plugin, escribe la cadena de conexión y elige una de +estas dos variantes: + +| Opción | Cuándo usarla | Qué tener en cuenta | +|---|---|---| +| **A. Base nueva y vacía** | Quieres partir de cero y comparar el resultado contra el backup | Crea antes la base y su usuario; el plugin no crea bases | +| **B. Reutilizar la base de 10.11.x** | Ya tenías PostgreSQL y no quieres mover nada | Funciona igual: activa **Truncar tablas antes de insertar** en el Paso 7 para que el import reemplace el contenido en lugar de chocar con las claves primarias existentes | + +Reutilizar la base original es totalmente válido —es la ruta que se usó en las pruebas de +este manual— y sólo conviene revisar los residuos de pruebas anteriores: + +- bases temporales de comprobación (`jellyfin_provider_test_*`) que hayan quedado atrás; +- ruido en `pg_stat_statements` si antes se ejecutaron diagnósticos: se limpia con + `SELECT pg_stat_statements_reset();`. -1. Escribe la cadena de conexión a una **base de datos nueva y vacía** - (no reutilices la base de la versión 2.x). -2. Pulsa **Probar conexion**. Debe responder `OK`. -3. Opcional: ajusta **Opciones avanzadas** (tamaño mínimo/máximo del pool, sentencias +1. Pulsa **Probar conexion**. Debe responder `OK`. +2. Opcional: ajusta **Opciones avanzadas** (tamaño mínimo/máximo del pool, sentencias preparadas, tiempo de espera) y pulsa **Guardar configuracion**. Los valores se reflejan - en **Current status**. + en **Current status** y los aplica el proveedor al activar PostgreSQL. ### Paso 7 — Importar SQLite a PostgreSQL 1. Pestaña **Migracion**, sección **Migrar datos de SQLite a PostgreSQL**. 2. Verifica la ruta de `jellyfin.db` (se autodetecta). -3. Deja **Truncar tablas antes de insertar** desactivado en una base vacía; actívalo si - repites la importación sobre datos existentes. +3. Con una base nueva, deja **Truncar tablas antes de insertar** desactivado; si reutilizas + la base de 10.11.x (opción B del Paso 6), **actívalo** para reemplazar el contenido + existente en lugar de duplicarlo. 4. Pulsa **Iniciar migracion** y espera al 100 %. 5. Al terminar, el panel habilita **Activar PostgreSQL y reiniciar Jellyfin**. @@ -274,6 +317,5 @@ terminar en pocos minutos; el cuello de botella es el disco, no la CPU. ## 9. Sigue leyendo - [Novedades de 3.0.0 frente a 2.0.1](novedades-3.0.0.md) -- [Plan de publicación de 2.0.1 y 3.0.0](plan-de-publicacion.md) - [Guía técnica de la adaptación a Jellyfin 12.1](JELLYFIN-12.1.md) - [Resumen de verificación automatizada](VERIFICACION-RESUMEN.md) diff --git a/docs/migration-10.11-to-12.en.md b/docs/migration-10.11-to-12.en.md index 7d17f2b..bec6a97 100644 --- a/docs/migration-10.11-to-12.en.md +++ b/docs/migration-10.11-to-12.en.md @@ -46,8 +46,8 @@ plugin's *Migration* tab. That export had a defect in 2.0.0.0 that **2.0.1** fix adds a **diagnostic** reporting table, row number and key values on constraint violations. Practical consequence: **anyone reaching Jellyfin 12 without a successful export from 10.11 -has no clean route for their data**. That is why 2.0.1 ships **before** 3.0.0 (see -[`release-plan.en.md`](release-plan.en.md)). +has no clean route for their data**. That is why 2.0.1 ships **before** 3.0.0: without an +export there is no bridge between both servers. --- @@ -129,10 +129,29 @@ In the **Configuration** tab press **Deactivate / Revert to SQLite** (*Revert to restart*). The plugin removes `/database.xml`, so **Jellyfin starts in SQLite** next time. +Check right after pressing the button that the file is gone: + ```powershell Test-Path "$env:LOCALAPPDATA\jellyfin\config\database.xml" # must be False ``` +> ℹ️ On startup Jellyfin **recreates** `config/database.xml` declaring SQLite. That is normal +> and it is the confirmation that the active engine is the right one: +> +> ```xml +> +> +> Jellyfin-SQLite +> NoLock +> +> ``` + +What must **not** appear in that file is `PLUGIN_PROVIDER`: + +```powershell +Select-String -Path "$env:LOCALAPPDATA\jellyfin\config\database.xml" -Pattern 'PLUGIN_PROVIDER' # no output +``` + ### Step 4 — Install Jellyfin 12.1 1. Stop Jellyfin 10.11 completely. @@ -145,10 +164,24 @@ log (`log/Postgres-YYYY-MM-DD.log`). ### Step 5 — Install plugin 3.0.0 -1. Download the **3.0.0.0** release ZIP into - `/plugins/PostgreSQL Database Provider_3.0.0.0/`. -2. Restart Jellyfin and confirm in **Dashboard → Plugins** that version 3.0.0.0 is listed and - the plugin log reports the active engine: +Two ways to do it; the first one is the smoothest: + +**Option A — from the catalog (recommended)** + +1. In Jellyfin 12.1 open **Dashboard → Plugins → Catalog**. +2. Find **PostgreSQL Database Provider** and install version **3.0.0.0**. +3. Accept the restart Jellyfin offers. + +**Option B — manual install** + +1. Download `Jellyfin.Database.Providers.Postgres-3.0.0.0.zip` from release **v3.0.0**. +2. Create `/plugins/PostgreSQL Database Provider_3.0.0.0/` and unzip its three + DLLs there: `Jellyfin.Database.Providers.Postgres.dll`, `Npgsql.dll` and + `Npgsql.EntityFrameworkCore.PostgreSQL.dll`. +3. Restart Jellyfin. + +Either way, confirm in **Dashboard → Plugins** that version 3.0.0.0 is listed and the plugin +log reports the active engine: ``` [ENGINE] Modo activo: SQLite. Archivo: ...\data\jellyfin.db (NNN.N MB) @@ -156,20 +189,33 @@ log (`log/Postgres-YYYY-MM-DD.log`). ### Step 6 — Configure the connection -In the plugin's **Configuration** tab: +In the plugin's **Configuration** tab, enter the connection string and pick one of these two +variants: + +| Option | When to use it | What to watch out for | +|---|---|---| +| **A. New, empty database** | You want a clean slate and to compare the result against your backup | Create the database and its role first; the plugin does not create databases | +| **B. Reuse the 10.11.x database** | You already had PostgreSQL and do not want to move anything | It works: enable **Truncate tables before insert** in Step 7 so the import replaces the existing content instead of colliding with existing primary keys | + +Reusing the original database is perfectly valid —it is the route used in this manual's tests— +and you only need to review leftovers from earlier tests: + +- temporary check databases (`jellyfin_provider_test_*`) left behind; +- `pg_stat_statements` noise after running diagnostics: clear it with + `SELECT pg_stat_statements_reset();`. -1. Enter a connection string to a **new, empty database** (do not reuse the 2.x database). -2. Press **Test connection**; it must answer `OK`. -3. Optional: tune **Advanced options** (minimum/maximum pool size, prepared statements, +1. Press **Test connection**; it must answer `OK`. +2. Optional: tune **Advanced options** (minimum/maximum pool size, prepared statements, command timeout) and press **Save configuration**. Values are reflected in - **Current status**. + **Current status** and the provider applies them when PostgreSQL is activated. ### Step 7 — Import SQLite into PostgreSQL 1. **Migration** tab → **Migrate data from SQLite to PostgreSQL**. 2. Check the `jellyfin.db` path (auto-detected). -3. Leave **Truncate tables before insert** off on an empty database; enable it when repeating - the import over existing data. +3. With a new database leave **Truncate tables before insert** off; when reusing the 10.11.x + database (option B in Step 6) **enable it** so the existing content is replaced instead of + duplicated. 4. Press **Start migration** and wait for 100 %. 5. When done, the panel enables **Activate PostgreSQL and restart Jellyfin**. @@ -267,6 +313,5 @@ minutes; disk, not CPU, is the bottleneck. ## 9. Keep reading - [What is new in 3.0.0 vs 2.0.1](whats-new-3.0.0.en.md) -- [Release plan for 2.0.1 and 3.0.0](release-plan.en.md) - [Technical guide for the Jellyfin 12.1 adaptation](JELLYFIN-12.1.md) - [Automated verification summary](VERIFICACION-RESUMEN.md) diff --git a/docs/novedades-3.0.0.md b/docs/novedades-3.0.0.md index d657edd..c51e9c3 100644 --- a/docs/novedades-3.0.0.md +++ b/docs/novedades-3.0.0.md @@ -168,5 +168,4 @@ la configuración con un valor fijo, y el estado actual no los mostraba. En 3.0. ## 10. Sigue leyendo - [Manual de migración de 10.11.x a 12.x](migracion-10.11-a-12.md) -- [Plan de publicación de 2.0.1 y 3.0.0](plan-de-publicacion.md) - [Guía técnica de la adaptación a Jellyfin 12.1](JELLYFIN-12.1.md) diff --git a/docs/plan-de-publicacion.md b/docs/plan-de-publicacion.md deleted file mode 100644 index f5b3ad8..0000000 --- a/docs/plan-de-publicacion.md +++ /dev/null @@ -1,182 +0,0 @@ -# Plan de publicación: 2.0.1 y 3.0.0 - -Cómo publicar las dos versiones que sostienen la migración a Jellyfin 12: -**2.0.1** (para Jellyfin 10.11.x) y **3.0.0** (para Jellyfin 12.1.x). - -> English version: [`release-plan.en.md`](release-plan.en.md) - ---- - -## 1. Por qué hay que publicar 2.0.1 antes que 3.0.0 - -El catálogo de Jellyfin **sirve un único `manifest.json`**: el de la rama `main`. Cada -versión del manifiesto declara su `targetAbi`, y el cliente descarga la versión **más alta -compatible** con el servidor del usuario. - -Consecuencias directas: - -1. Un usuario en **Jellyfin 10.11.x** sólo puede ver e instalar entradas con - `targetAbi 10.11.*`. Si 2.0.1 no está en el manifiesto, ese usuario se queda en 2.0.0.0. -2. Ese usuario **necesita 2.0.1** para exportar su PostgreSQL a SQLite sin toparse con el - error de clave primaria por mayúsculas/minúsculas (ver - [`migracion-10.11-a-12.md`](migracion-10.11-a-12.md), sección 2). -3. Sin export no hay puente hacia Jellyfin 12: son **dos releases con propósitos distintos**, - no dos intentos de la misma cosa. - -``` -v2.0.1 → rescata a los usuarios de 10.11 (export fiable a SQLite) -v3.0.0 → habilita Jellyfin 12.1 con el nuevo contrato de proveedores -``` - ---- - -## 2. Qué se corrigió en el flujo de release - -`release.yml` actualizaba el `manifest.json` **de la rama desde la que se lanzaba**. Al -publicar desde `release/2.0.1`, la entrada quedaba en esa rama y **nunca llegaba al catálogo** -(que se lee de `main`): el release existía, pero nadie lo veía. - -Ahora el flujo, tras crear la entrada en la rama de release, **replica la misma entrada en -`main`** mediante un worktree temporal y un commit del bot. La mejora está aplicada en: - -- rama `release/V3` (publica 3.0.0 con `targetAbi 12.1.0.0`), y -- rama `release/2.0.1` (publica 2.0.1 con `targetAbi 10.11.0.0`). - -El paso es idempotente: si `main` ya tiene esa versión, termina sin commit. - ---- - -## 3. Antes de publicar - -```bash -# 1. Ambas ramas compilan sin advertencias -git switch release/2.0.1 && dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release -git switch release/V3 && dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release - -# 2. La suite de 3.0.0 pasa en verde (68 pruebas) -$env:POSTGRES_TEST_CONNECTION = 'Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=***' -./Tools/Generar-ResumenEjecutivo.ps1 -``` - -Comprueba además que `main` y `release/V3` están alineadas en lo que respecta al plugin -(el manifiesto se actualiza solo, no lo edites a mano). - ---- - -## 4. Publicar 2.0.1 (primero) - -**GitHub → Actions → 📦 Release Plugin → Run workflow** - -| Entrada | Valor | -|---|---| -| `release_version` | `2.0.1` | -| `changelog` | ver más abajo | -| `prerelease` | `false` | -| Rama | `release/2.0.1` | - -Changelog sugerido: - -``` -2.0.1 — Fix crítico de exportación PostgreSQL → SQLite - -- Fix: las columnas que forman la clave primaria se copian sin normalizar, por lo que - los pares de filas que solo difieren en mayúsculas/minúsculas ya no colapsan en la - misma clave y el export completo deja de abortar. -- Fix: cuando hay una violación de constraint, el log indica tabla, número de fila y - valores de la clave en lugar del error criptico de SQLite. -- CI: la entrada del catálogo se publica también en main para que Jellyfin 10.11.x - pueda ver esta version. - -Requiere Jellyfin 10.11.x. Para Jellyfin 12.1 usa la version 3.0.0. -``` - -Resultado: tag `v2.0.1`, ZIP `Jellyfin.Database.Providers.Postgres-2.0.1.0.zip`, entrada -`2.0.1.0` con `targetAbi 10.11.0.0` en el manifiesto de `main`. - ---- - -## 5. Publicar 3.0.0 (después) - -**GitHub → Actions → 📦 Release Plugin → Run workflow** - -| Entrada | Valor | -|---|---| -| `release_version` | `3.0.0` | -| `changelog` | ver más abajo | -| `prerelease` | `false` | -| Rama | `release/V3` (o `main` tras fusionarla) | - -Changelog sugerido: - -``` -3.0.0 — Jellyfin 12.1 - -- Nuevo: adaptacion completa al contrato de proveedores de Jellyfin 12.1 (requiere .NET 10). -- Fix critico: PurgeDatabase implementado. En 2.x lanzaba NotImplementedException y rompia - la restauracion del respaldo completo del sistema. -- Nuevo: consola SQL de solo lectura en el panel, con 10 plantillas de diagnostico e historial. -- Nuevo: opciones avanzadas funcionales (tamano de pool, sentencias preparadas, timeout), - validadas y visibles en el estado actual. -- Nuevo: suite de pruebas xUnit (68 pruebas) con resumen ejecutivo autogenerado en CI. -- Mejor: log propio del plugin con detalle estructurado de PostgresException. -- Mejor: exportacion a SQLite mas robusta y arranque seguro en servidores ya migrados. - -Requiere Jellyfin 12.1.x. Para Jellyfin 10.11.x usa la version 2.0.1. -``` - -Resultado: tag `v3.0.0`, ZIP `Jellyfin.Database.Providers.Postgres-3.0.0.0.zip`, entrada -`3.0.0.0` con `targetAbi 12.1.0.0` en el manifiesto de `main`. - ---- - -## 6. Verificación posterior (obligatoria) - -```bash -# 6.a El manifiesto servido contiene las dos entradas y sus ABI -curl -s https://raw.githubusercontent.com/BORNIOS/Jellyfin-Database-Providers-Postgres/main/manifest.json \ - | jq '.[0].versions[] | {version, targetAbi}' - -# Esperado: -# { "version": "2.0.1.0", "targetAbi": "10.11.0.0" } -# { "version": "3.0.0.0", "targetAbi": "12.1.0.0" } - -# 6.b El checksum del manifiesto coincide con el ZIP descargado -curl -sL -o pgp-2.0.1.0.zip https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/download/v2.0.1/Jellyfin.Database.Providers.Postgres-2.0.1.0.zip -md5sum pgp-2.0.1.0.zip # debe coincidir con el campo checksum de la entrada - -# 6.c El ZIP trae los tres DLLs -unzip -l pgp-2.0.1.0.zip # Jellyfin.Database.Providers.Postgres.dll, Npgsql.dll, Npgsql.EntityFrameworkCore.PostgreSQL.dll -``` - -Prueba funcional mínima, en dos instalaciones limpias: - -1. **Jellyfin 10.11.11** → el catálogo ofrece 2.0.1; instálalo y ejecuta un export a SQLite. -2. **Jellyfin 12.1.x** → el catálogo ofrece 3.0.0; instálalo, importa ese SQLite y activa - PostgreSQL. - ---- - -## 7. Riesgos y notas - -- **No reutilices una versión ya publicada**: el flujo sustituye la entrada, pero el tag y el - ZIP del release anterior quedan huérfanos. Si necesitas corregir algo, publica 2.0.2 / 3.0.1. -- **Usa `prerelease: true`** si quieres validar el ZIP antes de exponerlo; el manifiesto se - actualiza igual, así que retíralo del manifiesto si decides no publicarlo. -- **Los ZIP no son intercambiables**: el de 2.0.1 lleva DLLs `net9.0` y el de 3.0.0 DLLs - `net10.0`. -- **El bot necesita permiso de escritura** (`permissions: contents: write`, ya declarado) para - crear el release y empujar el manifiesto. - -## 8. Vuelta atrás - -1. Borra el release y el tag en GitHub. -2. Revierte el commit del bot en `main` que añadió la entrada al manifiesto: - `git revert -- manifest.json` y `git push origin main`. -3. En el servidor, desinstala el plugin y vuelve a la versión anterior. - ---- - -## 9. Sigue leyendo - -- [Manual de migración de 10.11.x a 12.x](migracion-10.11-a-12.md) -- [Novedades de 3.0.0 frente a 2.0.1](novedades-3.0.0.md) diff --git a/docs/release-plan.en.md b/docs/release-plan.en.md deleted file mode 100644 index 1ac683b..0000000 --- a/docs/release-plan.en.md +++ /dev/null @@ -1,179 +0,0 @@ -# Release plan: 2.0.1 and 3.0.0 - -How to publish the two versions that carry the move to Jellyfin 12: -**2.0.1** (for Jellyfin 10.11.x) and **3.0.0** (for Jellyfin 12.1.x). - -> Spanish version (primary): [`plan-de-publicacion.md`](plan-de-publicacion.md) - ---- - -## 1. Why 2.0.1 must ship before 3.0.0 - -Jellyfin's catalog **serves a single `manifest.json`**: the one on `main`. Every manifest entry -declares its `targetAbi`, and the client downloads the **highest compatible** version for the -user's server. - -Direct consequences: - -1. A user on **Jellyfin 10.11.x** can only see and install entries with `targetAbi 10.11.*`. - If 2.0.1 is missing from the manifest, that user stays on 2.0.0.0. -2. That user **needs 2.0.1** to export PostgreSQL to SQLite without hitting the primary-key - letter-case error (see [`migration-10.11-to-12.en.md`](migration-10.11-to-12.en.md), - section 2). -3. Without an export there is no bridge to Jellyfin 12: these are **two releases with different - purposes**, not two attempts at the same thing. - -``` -v2.0.1 → rescues 10.11 users (reliable export to SQLite) -v3.0.0 → enables Jellyfin 12.1 with the new provider contract -``` - ---- - -## 2. What was fixed in the release flow - -`release.yml` used to update the `manifest.json` **of the branch it ran on**. Releasing from -`release/2.0.1` therefore left the entry on that branch and it **never reached the catalog** -(which is read from `main`): the release existed, but nobody could see it. - -The flow now **replicates the same entry into `main`** right after updating the release branch, -using a temporary worktree and a bot commit. The improvement is applied on: - -- branch `release/V3` (publishes 3.0.0 with `targetAbi 12.1.0.0`), and -- branch `release/2.0.1` (publishes 2.0.1 with `targetAbi 10.11.0.0`). - -The step is idempotent: if `main` already carries that version, it exits without committing. - ---- - -## 3. Before releasing - -```bash -# 1. Both branches build with no warnings -git switch release/2.0.1 && dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release -git switch release/V3 && dotnet build Jellyfin.Database.Providers.Postgres.sln -c Release - -# 2. The 3.0.0 suite is green (68 tests) -$env:POSTGRES_TEST_CONNECTION = 'Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=***' -./Tools/Generar-ResumenEjecutivo.ps1 -``` - -Also confirm `main` and `release/V3` are aligned for the plugin (the manifest updates itself; -never edit it by hand). - ---- - -## 4. Publish 2.0.1 (first) - -**GitHub → Actions → 📦 Release Plugin → Run workflow** - -| Input | Value | -|---|---| -| `release_version` | `2.0.1` | -| `changelog` | see below | -| `prerelease` | `false` | -| Branch | `release/2.0.1` | - -Suggested changelog: - -``` -2.0.1 — Critical PostgreSQL -> SQLite export fix - -- Fix: primary-key columns are copied verbatim, so row pairs that differ only by letter - case no longer collapse into the same key and the full export stops aborting. -- Fix: on a constraint violation the log reports table, row number and key values instead - of the cryptic SQLite error. -- CI: the catalog entry is also published to main so Jellyfin 10.11.x can see this version. - -Requires Jellyfin 10.11.x. Use 3.0.0 for Jellyfin 12.1. -``` - -Outcome: tag `v2.0.1`, ZIP `Jellyfin.Database.Providers.Postgres-2.0.1.0.zip`, manifest entry -`2.0.1.0` with `targetAbi 10.11.0.0` on `main`. - ---- - -## 5. Publish 3.0.0 (after) - -**GitHub → Actions → 📦 Release Plugin → Run workflow** - -| Input | Value | -|---|---| -| `release_version` | `3.0.0` | -| `changelog` | see below | -| `prerelease` | `false` | -| Branch | `release/V3` (or `main` once merged) | - -Suggested changelog: - -``` -3.0.0 — Jellyfin 12.1 - -- New: full adaptation to the Jellyfin 12.1 database provider contract (requires .NET 10). -- Critical fix: PurgeDatabase implemented. On 2.x it threw NotImplementedException and broke - restoration of the full system backup. -- New: read-only SQL console in the panel, with 10 diagnostic templates and history. -- New: working advanced options (pool size, prepared statements, timeout), validated and - shown in the current status. -- New: xUnit test suite (68 tests) with an executive summary generated in CI. -- Better: dedicated plugin log with structured PostgresException detail. -- Better: more robust SQLite export and safe startup on already migrated servers. - -Requires Jellyfin 12.1.x. Use 2.0.1 for Jellyfin 10.11.x. -``` - -Outcome: tag `v3.0.0`, ZIP `Jellyfin.Database.Providers.Postgres-3.0.0.0.zip`, manifest entry -`3.0.0.0` with `targetAbi 12.1.0.0` on `main`. - ---- - -## 6. Post-release verification (mandatory) - -```bash -# 6.a The served manifest holds both entries and their ABIs -curl -s https://raw.githubusercontent.com/BORNIOS/Jellyfin-Database-Providers-Postgres/main/manifest.json \ - | jq '.[0].versions[] | {version, targetAbi}' - -# Expected: -# { "version": "2.0.1.0", "targetAbi": "10.11.0.0" } -# { "version": "3.0.0.0", "targetAbi": "12.1.0.0" } - -# 6.b Manifest checksum matches the downloaded ZIP -curl -sL -o pgp-2.0.1.0.zip https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/download/v2.0.1/Jellyfin.Database.Providers.Postgres-2.0.1.0.zip -md5sum pgp-2.0.1.0.zip # must match the entry's checksum field - -# 6.c The ZIP carries the three DLLs -unzip -l pgp-2.0.1.0.zip # Jellyfin.Database.Providers.Postgres.dll, Npgsql.dll, Npgsql.EntityFrameworkCore.PostgreSQL.dll -``` - -Minimum functional test, on two clean installs: - -1. **Jellyfin 10.11.11** → the catalog offers 2.0.1; install it and run an export to SQLite. -2. **Jellyfin 12.1.x** → the catalog offers 3.0.0; install it, import that SQLite file and - activate PostgreSQL. - ---- - -## 7. Risks and notes - -- **Never reuse a published version**: the flow replaces the entry, but the old tag and ZIP are - left orphaned. If you must fix something, publish 2.0.2 / 3.0.1. -- **Use `prerelease: true`** to validate a ZIP before exposing it; the manifest is updated - anyway, so remove the entry if you decide not to publish it. -- **ZIPs are not interchangeable**: 2.0.1 carries `net9.0` DLLs, 3.0.0 carries `net10.0` DLLs. -- **The bot needs write access** (`permissions: contents: write`, already declared) to create - the release and push the manifest. - -## 8. Rollback - -1. Delete the release and the tag on GitHub. -2. Revert the bot commit on `main` that added the manifest entry: - `git revert -- manifest.json` and `git push origin main`. -3. On the server, uninstall the plugin and go back to the previous version. - ---- - -## 9. Keep reading - -- [Migration manual from 10.11.x to 12.x](migration-10.11-to-12.en.md) -- [What is new in 3.0.0 vs 2.0.1](whats-new-3.0.0.en.md) diff --git a/docs/whats-new-3.0.0.en.md b/docs/whats-new-3.0.0.en.md index 2468c69..a74f685 100644 --- a/docs/whats-new-3.0.0.en.md +++ b/docs/whats-new-3.0.0.en.md @@ -162,5 +162,4 @@ and the current status never showed them. In 3.0.0: ## 10. Keep reading - [Migration manual from 10.11.x to 12.x](migration-10.11-to-12.en.md) -- [Release plan for 2.0.1 and 3.0.0](release-plan.en.md) - [Technical guide for the Jellyfin 12.1 adaptation](JELLYFIN-12.1.md) From 155aef6de9a83541c5628d020441e1fcc17d798b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 11:16:55 -0700 Subject: [PATCH 13/15] docs: destacar la consola SQL en las caracteristicas principales - README (ES/EN): nueva caracteristica en la lista principal, con la aclaracion de que es de solo lectura y que rechaza sentencias que modifiquen datos. - manifest.json: la consola SQL se menciona en la descripcion (catalogo en ingles) y en el resumen en espanol que ve Jellyfin. --- README.en.md | 1 + README.md | 1 + manifest.json | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.en.md b/README.en.md index 409765d..0a04b86 100644 --- a/README.en.md +++ b/README.en.md @@ -52,6 +52,7 @@ automatic health check, scheduled maintenance and optional JellyTrend integratio - 💾 **Scheduled backups** with `pg_dump`, optional ZIP compression and restore from the UI. - ⚡ **Optional JellyTrend integration** — recommendations 4-10× faster with optimized native SQL. - 📊 **Database statistics** — total size, active connections and per-table metrics from the UI. +- 🧪 **Read-only SQL console** — run diagnostic queries from the panel with 10 templates and history; any data-modifying statement is rejected. - 🔁 **One-click rollback to SQLite** from the configuration tab. ## ⚙️ Compatibility diff --git a/README.md b/README.md index 371d678..ab97aa5 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ health check automático, mantenimiento programado e integración opcional con J - 💾 **Backups programados** con `pg_dump`, compresión ZIP opcional y restore desde la UI. - ⚡ **Integración opcional con JellyTrend** — recomendaciones 4-10× más rápidas con SQL nativo optimizado. - 📊 **Estadísticas de BD** — tamaño, conexiones activas y análisis tabla por tabla desde la UI. +- 🧪 **Consola SQL de solo lectura** — lanza consultas de diagnóstico desde el panel con 10 plantillas e historial; rechaza cualquier sentencia que modifique datos. - 🔁 **Rollback a SQLite** en un clic desde la configuración. --- diff --git a/manifest.json b/manifest.json index 05928c3..7f87e31 100644 --- a/manifest.json +++ b/manifest.json @@ -2,8 +2,8 @@ { "guid": "a2b5f3e8-4c1d-4f7a-9e6b-8d0c2f1a3b5e", "name": "PostgreSQL Database Provider", - "description": "Production-ready PostgreSQL backend for Jellyfin. Bidirectional SQLite migration, near-instant search, health diagnostics and scheduled maintenance — no core modifications required.", - "overview": "Reemplaza SQLite por PostgreSQL como motor de base de datos de Jellyfin. Incluye migración bidireccional SQLite↔PostgreSQL, búsqueda casi instantánea con índices GIN, health check automático al arrancar, mantenimiento programado (VACUUM/REINDEX/Backup) e integración opcional con JellyTrend.", + "description": "Production-ready PostgreSQL backend for Jellyfin. Bidirectional SQLite migration, near-instant search, health diagnostics, read-only SQL console and scheduled maintenance — no core modifications required.", + "overview": "Reemplaza SQLite por PostgreSQL como motor de base de datos de Jellyfin. Incluye migración bidireccional SQLite↔PostgreSQL, búsqueda casi instantánea con índices GIN, health check automático al arrancar, consola SQL de solo lectura, mantenimiento programado (VACUUM/REINDEX/Backup) e integración opcional con JellyTrend.", "owner": ".:BORNIOS:.", "category": "Database", "imageUrl": "https://raw.githubusercontent.com/BORNIOS/Jellyfin-Database-Providers-Postgres/main/Jellyfin.Database.Providers.Postgres/Resources/logo.png", From eaba2d1639ca16896d466ea4069f8bf7d8b19ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 11:21:27 -0700 Subject: [PATCH 14/15] ci(release): changelog por variable de entorno y entrada 2.0.1.0 en el catalogo - release.yml: el changelog llega por env en los tres pasos que lo usan (manifiesto de la rama, manifiesto de main y notas del release). Interpolado en el script, los saltos de linea se guardaban como \\n literales y Jellyfin los mostraba asi. - manifest.json: se anade la entrada 2.0.1.0 (targetAbi 10.11.0.0, checksum verificado) para conservarla cuando main se alinee con esta rama. --- .github/workflows/release.yml | 25 ++++++++++++++++--------- manifest.json | 8 ++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d82536d..e5a90d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,15 +79,19 @@ jobs: echo "checksum=$CHECKSUM" >> "$GITHUB_OUTPUT" - name: Actualizar manifest.json (repo) + # El changelog llega por variable de entorno, no interpolado en el script: así los saltos de + # línea sobreviven, jq los escribe como \n reales y Jellyfin los muestra como párrafos. + env: + VERSION: ${{ steps.version.outputs.full }} + RELEASE_VERSION: ${{ github.event.inputs.release_version }} + CHECKSUM: ${{ steps.package.outputs.checksum }} + CHANGELOG: ${{ github.event.inputs.changelog }} run: | - VERSION="${{ steps.version.outputs.full }}" # ABI que declara esta rama: 12.1.0.0 corresponde a Jellyfin 12.x. TARGETABI="12.1.0.0" ZIP="Jellyfin.Database.Providers.Postgres-${VERSION}.zip" - URL="https://github.com/${{ github.repository }}/releases/download/v${{ github.event.inputs.release_version }}/${ZIP}" - CHECKSUM="${{ steps.package.outputs.checksum }}" + URL="https://github.com/${{ github.repository }}/releases/download/v${RELEASE_VERSION}/${ZIP}" TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - CHANGELOG="${{ github.event.inputs.changelog }}" jq --arg v "$VERSION" --arg url "$URL" --arg sum "$CHECKSUM" --arg ts "$TS" --arg changelog "$CHANGELOG" --arg abi "$TARGETABI" \ '.[0].versions = (([.[0].versions[] | select(.version != $v)]) + [{version: $v, changelog: $changelog, targetAbi: $abi, sourceUrl: $url, checksum: $sum, timestamp: $ts}])' \ manifest.json > manifest.tmp.json && mv manifest.tmp.json manifest.json @@ -108,13 +112,14 @@ jobs: if: github.ref_name != 'main' env: TARGETABI: '12.1.0.0' + VERSION: ${{ steps.version.outputs.full }} + RELEASE_VERSION: ${{ github.event.inputs.release_version }} + CHECKSUM: ${{ steps.package.outputs.checksum }} + CHANGELOG: ${{ github.event.inputs.changelog }} run: | - VERSION="${{ steps.version.outputs.full }}" ZIP="Jellyfin.Database.Providers.Postgres-${VERSION}.zip" - URL="https://github.com/${{ github.repository }}/releases/download/v${{ github.event.inputs.release_version }}/${ZIP}" - CHECKSUM="${{ steps.package.outputs.checksum }}" + URL="https://github.com/${{ github.repository }}/releases/download/v${RELEASE_VERSION}/${ZIP}" TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - CHANGELOG="${{ github.event.inputs.changelog }}" git fetch origin main git worktree add --detach ../catalogo origin/main cd ../catalogo @@ -131,7 +136,9 @@ jobs: git push origin HEAD:main - name: Preparar notas del release - run: printf '%s' "${{ github.event.inputs.changelog }}" > release-notes.md + env: + CHANGELOG: ${{ github.event.inputs.changelog }} + run: printf '%s' "$CHANGELOG" > release-notes.md - name: Crear release en GitHub uses: softprops/action-gh-release@v2 diff --git a/manifest.json b/manifest.json index 7f87e31..210a7af 100644 --- a/manifest.json +++ b/manifest.json @@ -23,6 +23,14 @@ "sourceUrl": "https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/download/v2.0.0.0/Jellyfin.Database.Providers.Postgres-2.0.0.0.zip", "checksum": "f39697fa46c8f706a0eda85f69263684", "timestamp": "2026-08-29T05:45:47Z" + }, + { + "version": "2.0.1.0", + "changelog": "2.0.1 - Fix critico de exportacion PostgreSQL -> SQLite:\n\n- Fix: las columnas de la clave primaria se copian sin normalizar, asi que los pares de filas que solo difieren en mayusculas/minusculas ya no colapsan en la misma clave y el export deja de abortar.\n- Fix: cuando hay una violacion de constraint, el log indica tabla, numero de fila y valores de la clave en lugar del error criptico de SQLite.\n- CI: la entrada del catalogo se publica tambien en main para que Jellyfin 10.11.x pueda ver esta version.\n\nRequiere Jellyfin 10.11.x. Para Jellyfin 12.1 usa la version 3.0.0.", + "targetAbi": "10.11.0.0", + "sourceUrl": "https://github.com/BORNIOS/Jellyfin-Database-Providers-Postgres/releases/download/v2.0.1/Jellyfin.Database.Providers.Postgres-2.0.1.0.zip", + "checksum": "0d2f4217825e2e1b9edbe1d992855a2f", + "timestamp": "2026-09-16T18:19:47Z" } ] } From 6a5d8b851ecb99b6f7d1b476f80712b6acb8880a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Ch=C3=A1vez=20Bornios?= Date: Wed, 16 Sep 2026 11:25:41 -0700 Subject: [PATCH 15/15] test(schema): filtro de rutas multiplataforma en el auditor de esquema El auditor ignoraba bin, obj y Migrations con separadores de Windows, asi que en los runners Linux analizaba los archivos de migracion y fallaba con tablas auxiliares que no pertenecen al modelo EF (PostgresItemValueDuplicates y similares). - El filtro normaliza la ruta y usa separadores '/'. - Nueva prueba IgnoredSourcePathsMatchOnEveryPlatform con rutas de Windows y Linux. Suite de verificacion: 74 pruebas, 0 fallos. --- .../SchemaAuditTests.cs | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/Tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs b/Tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs index 3d94119..851e248 100644 --- a/Tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs +++ b/Tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs @@ -13,8 +13,9 @@ public sealed partial class SchemaAuditTests // Objects the plugin uses that are not part of Jellyfin's EF model (EF Core bookkeeping). private static readonly string[] BookkeepingTables = ["__EFMigrationsHistory", "__EFMigrationsLock"]; - // Directories of the plugin whose raw SQL does not target the live schema. - private static readonly string[] IgnoredSourcePaths = ["\\bin\\", "\\obj\\", "\\Migrations\\"]; + // Directories of the plugin whose raw SQL does not target the live schema. Paths use forward + // slashes and the file path is normalised before comparing, so Windows and Linux behave the same. + private static readonly string[] IgnoredSourcePaths = ["/bin/", "/obj/", "/Migrations/"]; [Fact] public void EveryTableAndQualifiedColumnUsedByRawSqlExistsInTheModel() @@ -31,7 +32,7 @@ public void EveryTableAndQualifiedColumnUsedByRawSqlExistsInTheModel() foreach (var file in Directory.EnumerateFiles(TestEnvironment.PluginSourceDirectory, "*.cs", SearchOption.AllDirectories)) { - if (IgnoredSourcePaths.Any(path => file.Contains(path, StringComparison.OrdinalIgnoreCase))) + if (IsIgnoredSourcePath(file)) { continue; } @@ -78,6 +79,28 @@ public void SnapshotMatchesTheJellyfinModel() Assert.False(context.Database.HasPendingModelChanges()); } + /// + /// Path filtering has to behave identically on the developer machine (backslashes) and on the Linux + /// runners (forward slashes): a separator-specific filter silently audited the migration files in CI. + /// + /// Source file path to classify. + /// Whether the file must be skipped by the audit. + [Theory] + [InlineData(@"D:\repo\Jellyfin.Database.Providers.Postgres\Migrations\20260915211546_Jellyfin121.cs", true)] + [InlineData("/home/runner/work/repo/Jellyfin.Database.Providers.Postgres/Migrations/20260915211546_Jellyfin121.cs", true)] + [InlineData(@"D:\repo\Jellyfin.Database.Providers.Postgres\obj\Debug\x.cs", true)] + [InlineData("/home/runner/work/repo/Jellyfin.Database.Providers.Postgres/obj/Debug/x.cs", true)] + [InlineData(@"D:\repo\Jellyfin.Database.Providers.Postgres\Services\MaintenanceService.cs", false)] + [InlineData("/home/runner/work/repo/Jellyfin.Database.Providers.Postgres/Services/MaintenanceService.cs", false)] + public void IgnoredSourcePathsMatchOnEveryPlatform(string filePath, bool expected) + => Assert.Equal(expected, IsIgnoredSourcePath(filePath)); + + private static bool IsIgnoredSourcePath(string filePath) + { + var normalized = filePath.Replace('\\', '/'); + return IgnoredSourcePaths.Any(path => normalized.Contains(path, StringComparison.OrdinalIgnoreCase)); + } + // Table references in raw SQL: FROM/JOIN/INTO/ON/UPDATE/COPY/TRUNCATE followed by a quoted identifier, // plus an optional qualified column ("Table"."Column" or "Table".Column). [GeneratedRegex(