diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0c2d779..bac990e 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: Suite de verificación + env: + POSTGRES_TEST_CONNECTION: Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres + 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 7f2ac1c..e5a90d5 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 @@ -79,15 +79,21 @@ 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" \ - '.[0].versions = (([.[0].versions[] | select(.version != $v)]) + [{version: $v, changelog: $changelog, targetAbi: "10.11.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,8 +105,40 @@ 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' + VERSION: ${{ steps.version.outputs.full }} + RELEASE_VERSION: ${{ github.event.inputs.release_version }} + CHECKSUM: ${{ steps.package.outputs.checksum }} + CHANGELOG: ${{ github.event.inputs.changelog }} + run: | + ZIP="Jellyfin.Database.Providers.Postgres-${VERSION}.zip" + URL="https://github.com/${{ github.repository }}/releases/download/v${RELEASE_VERSION}/${ZIP}" + TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + 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 + 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/.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/.github/workflows/verification.yml b/.github/workflows/verification.yml new file mode 100644 index 0000000..db8aa52 --- /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/.gitignore b/.gitignore index 68856f0..c8cbcf6 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/ 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/Jellyfin.Database.Providers.Postgres.sln b/Jellyfin.Database.Providers.Postgres.sln index 21bb076..f0d2b3a 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/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/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 dc9def1..9f49033 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; @@ -182,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 }); } @@ -303,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) { @@ -319,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; @@ -330,8 +381,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 +422,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 +434,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 +474,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 +508,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 +663,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 +812,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 +830,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 +1018,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 +1183,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 +1224,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/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..d5fbc10 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,13 +147,8 @@ public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOpt // Apply pool tuning and prepared-statement cache from plugin config var config = PostgresPlugin.Instance?.Configuration; - var csb = new NpgsqlConnectionStringBuilder(connStr) - { - MinPoolSize = config?.MinPoolSize ?? 4, - MaxPoolSize = config?.MaxPoolSize ?? 100, - MaxAutoPrepare = config?.MaxAutoPrepare ?? 50, - CommandTimeout = config?.CommandTimeout ?? 60, - }; + var commandTimeout = config?.CommandTimeout ?? 600; + var csb = BuildTunedConnectionString(connStr, config, commandTimeout); var tunedConnStr = csb.ToString(); @@ -154,22 +158,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 +199,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); @@ -195,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 @@ -206,7 +272,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 +299,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 +390,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 +445,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 +593,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 +614,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 +730,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 +801,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/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/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/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/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/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); +} diff --git a/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html b/Jellyfin.Database.Providers.Postgres/Web/configurationPage.html index 9d130e7..37107e8 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 */ @@ -196,6 +242,7 @@

PostgreSQL Databa + @@ -236,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.
@@ -518,6 +573,52 @@

Salud de la base de datos

+ + + @@ -678,7 +779,53 @@

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_databases: 'Databases in the server', + 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', + button_clear_history: 'Clear history', + 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.', + 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_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: { title_main: 'PostgreSQL Database Provider', @@ -829,7 +976,53 @@

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_databases: 'Bases de datos del servidor', + 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', + 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...', + 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.', + 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_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' } }; @@ -965,6 +1158,10 @@

Salud de la base de datos

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')); @@ -979,6 +1176,21 @@

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('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(); 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.')); @@ -1006,13 +1218,17 @@

Salud de la base de datos

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(';'); } @@ -1024,7 +1240,9 @@

Salud de la base de datos

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) { @@ -1143,6 +1361,175 @@

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\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_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; + 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; + saveQueryHistory(); + 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('btnClearQueryHistory').addEventListener('click', clearQueryHistory); + 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) { @@ -1169,6 +1556,17 @@

Salud de la base de datos

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; @@ -1214,14 +1612,21 @@

Salud de la base de datos

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 @@ -1230,6 +1635,7 @@

Salud de la base de datos

.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'); } @@ -1929,6 +2335,7 @@

Salud de la base de datos

applyLocalization(); document.getElementById('currentStatusBox').textContent = t('msg_loading', 'Loading...'); + loadQueryHistory(); document.querySelector('.pg-tab-btn[data-tab="config"]').click(); loadStatus(); })(); diff --git a/README.en.md b/README.en.md index 9be76c8..0a04b86 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,21 @@ 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 | +| [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 | + --- ## ✨ Features @@ -37,17 +52,19 @@ 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 | 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. --- @@ -79,49 +96,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. + +### ⚙️ Configuration — connection, pool and active engine -Manage connection, connection pool and external tool paths. +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**. --- @@ -161,14 +182,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 de8b553..ab97aa5 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,21 @@ 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 | +| [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 | + --- ## ✨ Características @@ -37,59 +52,99 @@ 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. --- +## ⚙️ 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` | +| Backup directory | Carpeta donde se guardan los backups | `/postgres-backups` | +| Backup compression | Comprimir el 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. +**Opciones avanzadas** ---- - -### 🩺 Health Check - -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**. --- @@ -129,19 +184,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 0000000..743fa9f Binary files /dev/null and b/Screenshots/Tab-Console.png differ 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 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..1d7807d --- /dev/null +++ b/Tests/Jellyfin.Database.Providers.Postgres.Tests/QueryConsoleTests.cs @@ -0,0 +1,136 @@ +using System.Text.RegularExpressions; +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)); + } + + /// + /// 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). + /// + [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..851e248 --- /dev/null +++ b/Tests/Jellyfin.Database.Providers.Postgres.Tests/SchemaAuditTests.cs @@ -0,0 +1,110 @@ +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. 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() + { + 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 (IsIgnoredSourcePath(file)) + { + 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()); + } + + /// + /// 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( + """(?:\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/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); +} diff --git a/Tools/Generar-ResumenEjecutivo.ps1 b/Tools/Generar-ResumenEjecutivo.ps1 new file mode 100644 index 0000000..559c66f --- /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 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..3e3d67c --- /dev/null +++ b/docs/JELLYFIN-12.1.md @@ -0,0 +1,86 @@ +# 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 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; la suite crea bases nuevas con prefijo `jellyfin_provider_test_`: + +```powershell +$env:POSTGRES_TEST_CONNECTION = 'Host=localhost;Port=55439;Database=postgres;Username=postgres' +dotnet test Tests/Jellyfin.Database.Providers.Postgres.Tests -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..2e5dc97 --- /dev/null +++ b/docs/VERIFICACION-RESUMEN.md @@ -0,0 +1,120 @@ +# 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-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 | +| Entorno | Microsoft Windows 10.0.26200 · SDK 10.0.401 | + +## Resultado global + +| Verificaciones | Correctas | Con error | Omitidas | +| --- | --- | --- | --- | +| 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 | +| ProviderContractTests | 4 | ✅ 4/4 | +| QueryConsoleTests | 28 | ✅ 28/28 | +| SchemaAuditTests | 2 | ✅ 2/2 | +| SqlRewriteTests | 9 | ✅ 9/9 | +| WebPageTests | 4 | ✅ 4/4 | + +## Detalle + +| Verificación | Resultado | +| --- | --- | +| AdvancedOptionTests.CommandTimeoutIsAlwaysApplied | ✅ | +| AdvancedOptionTests.PoolFallsBackToTheSavedConfiguration | ✅ | +| AdvancedOptionTests.PoolFromTheConnectionStringIsRespected | ✅ | +| AdvancedOptionTests.RequestDefaultsMatchThePluginDefaults | ✅ | +| AdvancedOptionTests.SavedConfigurationSurvivesTheRoundTrip | ✅ | +| 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.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;") | ✅ | +| 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 \""···) | ✅ | +| WebPageTests.BothDictionariesContainEveryTranslatedKey | ✅ | +| WebPageTests.ConfigurationPageResourceIsEmbedded | ✅ | +| WebPageTests.EveryElementIdUsedByTheScriptExists | ✅ | +| WebPageTests.EveryTabHasItsPane | ✅ | + +## 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/docs/migracion-10.11-a-12.md b/docs/migracion-10.11-a-12.md new file mode 100644 index 0000000..96976c3 --- /dev/null +++ b/docs/migracion-10.11-a-12.md @@ -0,0 +1,321 @@ +# 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: sin export no hay puente entre ambos servidores. + +--- + +## 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`, de +modo que la próxima vez **Jellyfin arranca en SQLite**. + +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. +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 + +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) + ``` + +### Paso 6 — Configurar la conexión + +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. 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** 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. 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**. + +### 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) +- [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..bec6a97 --- /dev/null +++ b/docs/migration-10.11-to-12.en.md @@ -0,0 +1,317 @@ +# 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: without an +export there is no bridge between both servers. + +--- + +## 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. + +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. +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 + +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) + ``` + +### Step 6 — Configure the connection + +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. 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** 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. 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**. + +### 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) +- [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..c51e9c3 --- /dev/null +++ b/docs/novedades-3.0.0.md @@ -0,0 +1,171 @@ +# 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) +- [Guía técnica de la adaptación a Jellyfin 12.1](JELLYFIN-12.1.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..a74f685 --- /dev/null +++ b/docs/whats-new-3.0.0.en.md @@ -0,0 +1,165 @@ +# 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) +- [Technical guide for the Jellyfin 12.1 adaptation](JELLYFIN-12.1.md) 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 diff --git a/manifest.json b/manifest.json index e99a7f4..210a7af 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", @@ -26,7 +26,7 @@ }, { "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.", + "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",